refactor(cv): extract customization panel
This commit is contained in:
@@ -9,6 +9,7 @@ Updated: 2026-09-01
|
||||
- Restored visibly distinct CV templates by clearing stale structural overrides when a template changes while preserving CV content and deliberate colour, typography, spacing and document settings. The live A4/Letter preview now fits its pane automatically and responds to pane and viewport resizing; manual zoom remains available.
|
||||
- Consolidated both template choosers into one ATS-first gallery backed by real rendered sample documents. ATS-safe designs are prioritised and explained, decorative sidebar layouts are not falsely labelled ATS-friendly, existing non-ATS selections remain visible, and all designs remain one click away.
|
||||
- Removed Docker Compose warnings for deliberately optional Microsoft-tenant and production-relative frontend API variables; missing required provider configuration remains enforced by deployment preflight.
|
||||
- Extracted the CV template/design/layout controls from the monolithic editor into a dedicated typed component while preserving settings, accessibility, localization and persistence behavior. The editor dropped below 1,200 lines and both creation/editor template flows continue sharing the same gallery.
|
||||
- Diagnosed Gitea Actions run 696 from its authenticated job log: application tests passed, but repeated truncated npm/Playwright downloads were followed by a native exit 139 before browser assertions ran. CI now invokes the lockfile-installed Playwright CLI directly, extends the bounded download timeout, retries only SIGSEGV, and does not redownload the same dependency tree after a transient runner crash.
|
||||
- Removed the unreachable legacy `JobDetailsDialog`, its duplicate insight tabs/panels, and tests coupled only to that retired surface. Live correspondence and routed-workspace contracts retain focused coverage; the cleanup removed 2,239 lines without changing the active Job Workspace.
|
||||
- Began the controller-responsibility split by moving job statistics, monthly analytics, tag counts, overview analytics, and tag trends into one dedicated authenticated analytics controller. Existing `api/jobapplications/*` routes and response contracts are unchanged; the shared tag parser replaces a byte-for-byte duplicate local parser.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box, Button, Divider, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Paper, Select, Slider, Stack, Switch, TextField, Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { applyCvTheme, CvTheme, CvVariantSettings, normalizeCvLanguage, SECTION_LABELS } from "../../cvBuilder";
|
||||
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"];
|
||||
|
||||
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 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],
|
||||
});
|
||||
|
||||
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>}
|
||||
|
||||
{mode === "customize" && <>
|
||||
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{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>{t("cvEditorHeadingFont")}</InputLabel><Select 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>{t("cvEditorBodyFont")}</InputLabel><Select 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>
|
||||
</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>{t("cvEditorHeadingTreatment")}</InputLabel><Select 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>{t("cvEditorHeaderTreatment")}</InputLabel><Select 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")} />}
|
||||
</>}
|
||||
</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>;
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
|
||||
MenuItem, Paper, Select, Skeleton, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
|
||||
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, Divider, IconButton,
|
||||
MenuItem, Paper, Skeleton, Slider, Stack, Tab, Tabs, TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
@@ -31,26 +31,17 @@ import { useToast } from "../toast";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import RichTextField from "../components/RichTextField";
|
||||
import AiSectionAssistant from "../components/cv/AiSectionAssistant";
|
||||
import CvCustomizeTab from "../components/cv/CvCustomizeTab";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import CvTemplateGallery from "../components/CvTemplateGallery";
|
||||
import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
||||
applyCvTheme, cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
} from "../cvBuilder";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
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 MIN_PREVIEW_ZOOM = 0.32;
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
||||
|
||||
@@ -480,9 +471,9 @@ export default function CvBuilderEditor() {
|
||||
<Tab label={t("cvEditorHistory")} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && <CustomizeTab mode="template" settings={settings} update={update} themes={themes} />}
|
||||
{tab === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{tab === 1 && <ContentTab settings={settings} update={update} outline={outline} />}
|
||||
{tab === 2 && <CustomizeTab mode="customize" settings={settings} update={update} themes={themes} />}
|
||||
{tab === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{tab === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
|
||||
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
</Paper>
|
||||
@@ -1130,90 +1121,6 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Customize tab ----------
|
||||
|
||||
function CustomizeTab({ mode, settings, update, themes }: {
|
||||
mode: "template" | "customize";
|
||||
settings: CvVariantSettings;
|
||||
update: (p: Partial<CvVariantSettings>) => void;
|
||||
themes: CvTheme[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
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],
|
||||
});
|
||||
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>}
|
||||
|
||||
{mode === "customize" && <>
|
||||
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{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={(e) => update({ accentColor: e.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>{t("cvEditorHeadingFont")}</InputLabel><Select label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorBodyFont")}</InputLabel><Select label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||
</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>{t("cvEditorHeadingTreatment")}</InputLabel><Select label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.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>{t("cvEditorHeaderTreatment")}</InputLabel><Select label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.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={(e) => update({ skillsStyle: e.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) }; }) })} />}</>}
|
||||
</>}
|
||||
|
||||
{mode === "customize" && <>
|
||||
<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={(e) => update({ pageSize: e.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={(e) => update({ density: e.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={(e) => update({ language: e.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={(e) => update({ dateFormat: e.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={(e) => update({ layout: (e.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") : sectionName(key, SECTION_LABELS[key] ?? key, t)} />)}</Stack>
|
||||
</Paper>}
|
||||
<Divider />
|
||||
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label={t("cvEditorShowPhoto")} />}
|
||||
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label={t("cvEditorContactIcons")} />}
|
||||
</>}
|
||||
</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 HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) {
|
||||
const { language, t } = useI18n();
|
||||
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">{t("cvEditorNoVersions")}</Typography>;
|
||||
|
||||
Reference in New Issue
Block a user