feat(cv): organize template and document controls

This commit is contained in:
cesnimda
2026-10-08 00:16:44 +02:00
parent 673cff1145
commit d8d262e4d1
99 changed files with 27605 additions and 82 deletions
@@ -1,21 +1,22 @@
import React, { useId } from "react";
import React, { useEffect, useId, useState } from "react";
import CheckIcon from "@mui/icons-material/Check";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Box, Button, Divider, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Paper, Select, Slider, Stack, Switch, TextField, Typography,
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",
"'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";
@@ -27,77 +28,141 @@ type Props = {
export default function CvCustomizeTab({ mode, settings, update, themes, sectionLabel }: Props) {
const { t } = useI18n();
const labelId = useId();
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
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 toggleSidebarSection = (key: string) => update({
sidebarSections: sidebarSections.includes(key)
? sidebarSections.filter((item) => item !== key)
: [...sidebarSections, key],
});
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]])];
return (
<Stack spacing={2}>
{mode === "template" && <Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>{t("cvEditorThemeHelp")}</Typography>
<CvTemplateGallery themes={themes} selectedId={settings.themeId} onSelect={(theme) => update(applyCvTheme(settings, theme.id))} />
</Box>}
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>;
{mode === "customize" && <>
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorColour")}</Typography>
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 })} onClick={() => update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
))}
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", bgcolor: settings.accentColor ?? "#3157d5", border: "2px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden", position: "relative" }}>
<input type="color" aria-label={t("cvEditorCustomAccent")} value={settings.accentColor ?? "#3157d5"} onChange={(event) => update({ accentColor: event.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
</Box>
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>{t("cvEditorThemeDefault")}</Button>}
</Stack></>}
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>{t("cvEditorTypography")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingFont`}>{t("cvEditorHeadingFont")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingFont`} label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorBodyFont`}>{t("cvEditorBodyFont")}</InputLabel><Select labelId={`${labelId}-cvEditorBodyFont`} label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
{["#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>
<ControlSlider label={t("cvEditorBodySize")} value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
<ControlSlider label={t("cvEditorHeadingSize")} value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
<ControlSlider label={t("cvEditorLineHeight")} value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
{supports("heading") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingTreatment`}>{t("cvEditorHeadingTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingTreatment`} label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
{supports("header") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeaderTreatment`}>{t("cvEditorHeaderTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeaderTreatment`} label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>{t("cvEditorSkillsPresentation")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorSkillsPresentation") }} label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? "tags"} onChange={(event) => update({ skillsStyle: event.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">{t("cvEditorTags")}</MenuItem><MenuItem value="text">{t("cvEditorCompactText")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="grouped">{t("cvEditorGroupedSkills")}</MenuItem></Select></FormControl>
{settings.skillsStyle === "grouped" && <TextField label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp")} 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) }; }) })} />}</>}
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorDocument")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
{supports("page") && <>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorPageSize")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorPageSize") }} label={t("cvEditorPageSize")} value={settings.pageSize ?? "a4"} onChange={(event) => update({ pageSize: event.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">{t("cvEditorUsLetter")}</MenuItem></Select></FormControl>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDensity")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDensity") }} label={t("cvEditorDensity")} value={settings.density ?? "balanced"} onChange={(event) => update({ density: event.target.value })}><MenuItem value="compact">{t("cvEditorCompact")}</MenuItem><MenuItem value="balanced">{t("cvEditorBalanced")}</MenuItem><MenuItem value="roomy">{t("cvEditorRoomy")}</MenuItem></Select></FormControl>
</>}
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDocumentLanguage")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDocumentLanguage") }} label={t("cvEditorDocumentLanguage")} value={normalizeCvLanguage(settings.language)} onChange={(event) => update({ language: event.target.value })}><MenuItem value="en">{t("languageEnglish")}</MenuItem><MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem></Select></FormControl>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDateFormat")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDateFormat") }} label={t("cvEditorDateFormat")} value={settings.dateFormat ?? "short"} onChange={(event) => update({ dateFormat: event.target.value })}><MenuItem value="long">{t("cvEditorDateLongExample")}</MenuItem><MenuItem value="short">{t("cvEditorDateShortExample")}</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
</Box>
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorColumns")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorColumns") }} label={t("cvEditorColumns")} value={settings.layout ?? ""} onChange={(event) => update({ layout: (event.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="single">{t("cvEditorOneColumn")}</MenuItem><MenuItem value="header-band">{t("cvEditorHeaderBandColumn")}</MenuItem><MenuItem value="sidebar-left">{t("cvEditorLeftSidebar")}</MenuItem><MenuItem value="sidebar-right">{t("cvEditorRightSidebar")}</MenuItem></Select></FormControl>}
{supports("spacing") && <>
<ControlSlider label={t("cvEditorPageMargins")} value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
<ControlSlider label={t("cvEditorSectionSpacing")} value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
<ControlSlider label={t("cvEditorEntrySpacing")} value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
</>}
{supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("cvEditorSidebarContent")}</Typography>
<ControlSlider label={t("cvEditorSidebarWidth")} value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
<Stack>{["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)} />)}</Stack>
</Paper>}
<Divider />
{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")} />}
<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 ControlSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
return <Box><Stack direction="row" justifyContent="space-between" alignItems="baseline"><Typography variant="body2">{label}</Typography><Typography variant="caption" color="text.secondary">{Number(value.toFixed(2))}{suffix}</Typography></Stack><Slider size="small" aria-label={label} value={value} min={min} max={max} step={step} onChange={(_, next) => onChange(next as number)} /></Box>;
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>;
}