Files
jobtrackingapp/job-tracker-ui/src/components/cv/CvCustomizeTab.tsx
T

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>;
}