169 lines
15 KiB
TypeScript
169 lines
15 KiB
TypeScript
import React, { useEffect, useId, useState } from "react";
|
|
import CheckIcon from "@mui/icons-material/Check";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import {
|
|
Accordion, AccordionDetails, AccordionSummary, Box, Button, FormControl, FormControlLabel,
|
|
IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, Switch, TextField, Typography,
|
|
} from "@mui/material";
|
|
|
|
import { applyCvTheme, CvTheme, CvVariantSettings, normalizeCvLanguage, SECTION_LABELS } from "../../cvBuilder";
|
|
import { useConfirm } from "../../confirm";
|
|
import { useI18n } from "../../i18n/I18nProvider";
|
|
import CvTemplateGallery from "../CvTemplateGallery";
|
|
|
|
const FONTS = [
|
|
"'Segoe UI', Roboto, Arial, sans-serif", "Arial, Helvetica, sans-serif", "Georgia, 'Times New Roman', serif",
|
|
"'Helvetica Neue', Arial, sans-serif", "'Roboto', Arial, sans-serif", "'Poppins', 'Segoe UI', Arial, sans-serif",
|
|
];
|
|
const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"];
|
|
const CONTROL_GRID = { display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 };
|
|
|
|
type Props = {
|
|
mode: "template" | "customize";
|
|
settings: CvVariantSettings;
|
|
update: (patch: Partial<CvVariantSettings>) => void;
|
|
themes: CvTheme[];
|
|
sectionLabel: (key: string, fallback: string) => string;
|
|
};
|
|
|
|
export default function CvCustomizeTab({ mode, settings, update, themes, sectionLabel }: Props) {
|
|
const { t } = useI18n();
|
|
const { confirm } = useConfirm();
|
|
const id = useId();
|
|
const activeTheme = themes.find(theme => theme.id === settings.themeId);
|
|
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
|
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
|
const layout = settings.layout ?? activeTheme?.layout;
|
|
const selectTemplate = async (theme: CvTheme) => {
|
|
const next = applyCvTheme(settings, theme.id);
|
|
const resetsOverrides = Object.entries(next).some(([key, value]) => value === null && settings[key as keyof CvVariantSettings] != null);
|
|
if (resetsOverrides && !await confirm({ title: t("cvEditorChangeTemplateTitle"), message: t("cvEditorChangeTemplateMessage"), confirmLabel: t("cvEditorApplyTemplate") })) return;
|
|
update(next);
|
|
};
|
|
const toggleSidebarSection = (key: string) => update({ sidebarSections: sidebarSections.includes(key) ? sidebarSections.filter(item => item !== key) : [...sidebarSections, key] });
|
|
const defaultOption: [string, string] = ["", t("cvEditorTemplateDefault")];
|
|
const fonts: [string, string][] = [defaultOption, ...FONTS.map((font, index): [string, string] => [font, FONT_LABELS[index]])];
|
|
|
|
if (mode === "template") return <Stack spacing={1.5}>
|
|
<Typography component="h3" variant="subtitle2">{t("cvEditorTemplate")}</Typography>
|
|
<Typography variant="body2" color="text.secondary">{t("cvEditorThemeHelp")}</Typography>
|
|
<CvTemplateGallery themes={themes} selectedId={settings.themeId} onSelect={selectTemplate} />
|
|
</Stack>;
|
|
|
|
return <Stack spacing={2.5}>
|
|
<Typography variant="body2" color="text.secondary">{t("cvEditorAppearanceHelp")}</Typography>
|
|
<Stack component="section" aria-labelledby={id + "-style"} spacing={1.5}>
|
|
<Typography id={id + "-style"} component="h3" variant="subtitle2">{t("cvEditorStyle")}</Typography>
|
|
{supports("accent") && <Stack spacing={1}>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorColour")}</Typography>
|
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
|
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map(color => <IconButton key={color}
|
|
aria-label={t("cvEditorUseAccent", { colour: color })} aria-pressed={settings.accentColor === color}
|
|
onClick={() => update({ accentColor: color })} sx={{ width: 40, height: 40, border: "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider" }}>
|
|
<Box sx={{ width: 22, height: 22, borderRadius: "50%", bgcolor: color }} />
|
|
{settings.accentColor === color && <CheckIcon sx={{ position: "absolute", right: -2, bottom: -2, fontSize: 16, bgcolor: "background.paper", color: "text.primary", borderRadius: "50%" }} />}
|
|
</IconButton>)}
|
|
</Stack>
|
|
<TextField size="small" type="color" label={t("cvEditorCustomAccent")} value={settings.accentColor ?? activeTheme?.accent ?? "#3157d5"}
|
|
onChange={event => update({ accentColor: event.target.value })} slotProps={{ inputLabel: { shrink: true }, htmlInput: { "aria-label": t("cvEditorCustomAccent"), style: { height: 28, cursor: "pointer" } } }} />
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorAccentHelp")}</Typography>
|
|
{settings.accentColor && <Button size="small" sx={{ alignSelf: "flex-start" }} aria-label={t("cvEditorUseTemplateSetting", { setting: t("cvEditorColour") })} onClick={() => update({ accentColor: null })}>{t("cvEditorTemplateDefault")}</Button>}
|
|
</Stack>}
|
|
{supports("typography") && <>
|
|
<Box sx={CONTROL_GRID}>
|
|
<ControlSelect label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} options={fonts} onChange={value => update({ headingFont: value || null })} />
|
|
<ControlSelect label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} options={fonts} onChange={value => update({ bodyFont: value || null })} />
|
|
<ControlNumber label={t("cvEditorBodySize")} value={settings.baseFontSizePt} min={7} max={13} step={0.25} unit="pt" onChange={value => update({ baseFontSizePt: value })} />
|
|
<ControlNumber label={t("cvEditorHeadingSize")} value={settings.headingSizePt} min={9} max={20} step={0.5} unit="pt" onChange={value => update({ headingSizePt: value })} />
|
|
</Box>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorFontHelp")}</Typography>
|
|
</>}
|
|
</Stack>
|
|
|
|
<Stack component="section" aria-labelledby={id + "-layout"} spacing={1.5}>
|
|
<Typography id={id + "-layout"} component="h3" variant="subtitle2">{t("cvEditorLayout")}</Typography>
|
|
{supports("page") && <ControlSelect label={t("cvEditorPageSize")} value={settings.pageSize ?? "a4"} options={[["a4", "A4"], ["letter", t("cvEditorUsLetter")]]} onChange={pageSize => update({ pageSize })} />}
|
|
{supports("spacing") && <>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorMarginHelp")}</Typography>
|
|
<Box sx={CONTROL_GRID}>
|
|
<ControlNumber label={t("cvEditorPageMargins")} value={settings.pageMarginMm} min={8} max={28} step={1} unit="mm" onChange={value => update({ pageMarginMm: value })} />
|
|
<ControlNumber label={t("cvEditorSectionSpacing")} value={settings.sectionGapMm} min={2} max={14} step={0.5} unit="mm" onChange={value => update({ sectionGapMm: value })} />
|
|
<ControlNumber label={t("cvEditorEntrySpacing")} value={settings.entryGapMm} min={1} max={10} step={0.5} unit="mm" onChange={value => update({ entryGapMm: value })} />
|
|
</Box>
|
|
</>}
|
|
</Stack>
|
|
|
|
<Accordion disableGutters elevation={0} sx={{ border: "1px solid", borderColor: "divider", "&:before": { display: "none" } }}>
|
|
<AccordionSummary id={id + "-advanced"} aria-controls={id + "-advanced-content"} expandIcon={<ExpandMoreIcon />}>
|
|
<Typography component="h3" variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorAdvancedSettings")}</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails><Stack spacing={2}>
|
|
<Typography variant="body2" color="text.secondary">{t("cvEditorAdvancedHelp")}</Typography>
|
|
{activeTheme?.supportedSettings?.length ? <Typography variant="caption" color="text.secondary">{t("cvEditorTemplateCompatibility")}</Typography> : null}
|
|
<Box sx={CONTROL_GRID}>
|
|
{supports("typography") && <ControlNumber label={t("cvEditorLineHeight")} value={settings.lineHeight} min={1.1} max={1.8} step={0.02} onChange={value => update({ lineHeight: value })} />}
|
|
{supports("heading") && <ControlSelect label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} options={[defaultOption, ["caps-rule", t("cvEditorUppercaseDivider")], ["underline", t("richTextUnderline")], ["plain", t("cvEditorPlain")], ["bar", t("cvEditorAccentBar")]]} onChange={value => update({ headingStyle: (value || null) as CvVariantSettings["headingStyle"] })} />}
|
|
{supports("header") && <ControlSelect label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} options={[defaultOption, ["plain", t("cvEditorPlain")], ["band", t("cvEditorColourBand")], ["centered", t("cvEditorCentred")], ["kicker", t("cvEditorEditorial")]]} onChange={value => update({ headerStyle: (value || null) as CvVariantSettings["headerStyle"] })} />}
|
|
{supports("skills") && <ControlSelect label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? ""} options={[defaultOption, ["tags", t("cvEditorTags")], ["text", t("cvEditorCompactText")], ["bullets", t("cvEditorBulletList")], ["grouped", t("cvEditorGroupedSkills")]]} onChange={value => update({ skillsStyle: (value || null) as CvVariantSettings["skillsStyle"] })} />}
|
|
{supports("page") && <ControlSelect label={t("cvEditorDensity")} value={settings.density ?? "balanced"} options={[["compact", t("cvEditorCompact")], ["balanced", t("cvEditorBalanced")], ["roomy", t("cvEditorRoomy")]]} onChange={density => update({ density })} />}
|
|
<ControlSelect label={t("cvEditorDocumentLanguage")} value={normalizeCvLanguage(settings.language)} options={[["en", t("languageEnglish")], ["nb-NO", t("languageNorwegianBokmal")]]} onChange={language => update({ language })} />
|
|
<ControlSelect label={t("cvEditorDateFormat")} value={settings.dateFormat ?? ""} options={[["", t("cvEditorSourceDates")], ["long", t("cvEditorDateLongExample")], ["short", t("cvEditorDateShortExample")], ["numeric", "01/2020"], ["year", "2020"]]} onChange={dateFormat => update({ dateFormat: dateFormat || null })} />
|
|
{supports("layout") && <ControlSelect label={t("cvEditorColumns")} value={settings.layout ?? ""} options={[defaultOption, ["single", t("cvEditorOneColumn")], ["header-band", t("cvEditorHeaderBandColumn")], ["sidebar-left", t("cvEditorLeftSidebar")], ["sidebar-right", t("cvEditorRightSidebar")]]} onChange={value => update({ layout: (value || null) as CvVariantSettings["layout"] })} />}
|
|
</Box>
|
|
{supports("skills") && (settings.skillsStyle === "grouped" || !!settings.skillGroups?.length) && <TextField size="small" label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp") + " " + t("cvEditorSkillGroupsOverride")} multiline minRows={3}
|
|
value={(settings.skillGroups ?? []).map(group => group.name + ": " + group.items.join(", ")).join("\n")}
|
|
onChange={event => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map(line => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map(item => item.trim()).filter(Boolean) }; }) })} />}
|
|
{supports("sidebar") && (layout === "sidebar-left" || layout === "sidebar-right") && <Stack spacing={1}>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorSidebarContent")}</Typography>
|
|
<ControlNumber label={t("cvEditorSidebarWidth")} value={settings.sidebarWidthMm} min={45} max={85} step={1} unit="mm" onChange={value => update({ sidebarWidthMm: value })} />
|
|
{settings.sidebarSections == null ? <>
|
|
<Typography variant="caption" color="text.secondary">{t("cvEditorDefaultValue")}. {t("cvEditorSidebarDefaultHelp")}</Typography>
|
|
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => update({ sidebarSections })}>{t("cvEditorCustomizeSidebar")}</Button>
|
|
</> : <>
|
|
<Box sx={CONTROL_GRID}>{["contact", "skills", "languages", "certifications", "projects", "interests"].map(key => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={key === "contact" ? t("cvEditorContactDetails") : sectionLabel(key, SECTION_LABELS[key] ?? key)} />)}</Box>
|
|
<Button size="small" sx={{ alignSelf: "flex-start" }} aria-label={t("cvEditorUseTemplateSetting", { setting: t("cvEditorSidebarContent") })} onClick={() => update({ sidebarSections: null })}>{t("cvEditorTemplateDefault")}</Button>
|
|
</>}
|
|
</Stack>}
|
|
<Stack>
|
|
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />}
|
|
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={event => update({ showIcons: event.target.checked })} />} label={t("cvEditorContactIcons")} />}
|
|
</Stack>
|
|
</Stack></AccordionDetails>
|
|
</Accordion>
|
|
</Stack>;
|
|
}
|
|
|
|
function ControlSelect({ label, value, options, onChange }: { label: string; value: string; options: [string, string][]; onChange: (value: string) => void }) {
|
|
const id = useId();
|
|
const { t } = useI18n();
|
|
return <FormControl size="small" fullWidth>
|
|
<InputLabel id={id} shrink>{label}</InputLabel>
|
|
<Select displayEmpty labelId={id} label={label} value={value} onChange={event => onChange(event.target.value)}>
|
|
{!options.some(([key]) => key === value) && <MenuItem value={value}>{t("cvEditorSavedValue", { value })}</MenuItem>}
|
|
{options.map(([key, name]) => <MenuItem key={key} value={key}>{name}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>;
|
|
}
|
|
|
|
function ControlNumber({ label, value, min, max, step, unit = "", onChange }: { label: string; value?: number | null; min: number; max: number; step: number; unit?: string; onChange: (value: number | null) => void }) {
|
|
const { t } = useI18n();
|
|
const [draft, setDraft] = useState(value?.toString() ?? "");
|
|
const [invalid, setInvalid] = useState(false);
|
|
useEffect(() => { setDraft(value?.toString() ?? ""); setInvalid(false); }, [value]);
|
|
return <Box sx={{ minWidth: 0 }}>
|
|
<TextField fullWidth size="small" type="number" label={label} value={draft} placeholder={t("cvEditorTemplateDefault")} error={invalid}
|
|
helperText={invalid ? t("cvEditorNumberRange", { min, max, step }) : value == null ? t("cvEditorDefaultValue") : " "}
|
|
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min, max, step }, input: { endAdornment: unit ? <InputAdornment position="end">{unit}</InputAdornment> : undefined } }}
|
|
onChange={event => {
|
|
const input = event.target as HTMLInputElement;
|
|
setDraft(input.value);
|
|
const number = Number(input.value);
|
|
const steps = (number - min) / step;
|
|
const valid = !input.validity.badInput && (input.value === "" || (Number.isFinite(number) && number >= min && number <= max && Math.abs(steps - Math.round(steps)) < 1e-8));
|
|
setInvalid(!valid);
|
|
if (valid) onChange(input.value === "" ? null : number);
|
|
}} />
|
|
{value != null && <Button size="small" aria-label={t("cvEditorUseTemplateSetting", { setting: label })} onClick={() => onChange(null)}>{t("cvEditorTemplateDefault")}</Button>}
|
|
</Box>;
|
|
}
|