From bc4394c69261a28e607aaa904929d13d6b8b2493 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 1 Sep 2026 09:23:03 +0200 Subject: [PATCH] refactor(cv): extract customization panel --- docs/work-programmes/master-progress.md | 1 + .../src/components/cv/CvCustomizeTab.tsx | 102 +++++++++++++++++ job-tracker-ui/src/views/CvBuilderEditor.tsx | 105 +----------------- 3 files changed, 109 insertions(+), 99 deletions(-) create mode 100644 job-tracker-ui/src/components/cv/CvCustomizeTab.tsx diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 7d1b6cc..519d331 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -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. diff --git a/job-tracker-ui/src/components/cv/CvCustomizeTab.tsx b/job-tracker-ui/src/components/cv/CvCustomizeTab.tsx new file mode 100644 index 0000000..91526b4 --- /dev/null +++ b/job-tracker-ui/src/components/cv/CvCustomizeTab.tsx @@ -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) => 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 ( + + {mode === "template" && + {t("cvEditorTheme")} + {t("cvEditorThemeHelp")} + update(applyCvTheme(settings, theme.id))} /> + } + + {mode === "customize" && <> + {supports("accent") && <>{t("cvEditorColour")} + + {["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => ( + 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 } }} /> + ))} + + update({ accentColor: event.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} /> + + {settings.accentColor && } + } + + {supports("typography") && <>{t("cvEditorTypography")} + + {t("cvEditorHeadingFont")} + {t("cvEditorBodyFont")} + + update({ baseFontSizePt: value })} /> + update({ headingSizePt: value })} /> + update({ lineHeight: value })} />} + {supports("heading") && {t("cvEditorHeadingTreatment")}} + {supports("header") && {t("cvEditorHeaderTreatment")}} + {supports("skills") && <>{t("cvEditorSkillsPresentation")} + {settings.skillsStyle === "grouped" && `${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) }; }) })} />}} + + {t("cvEditorDocument")} + + {supports("page") && <> + {t("cvEditorPageSize")} + {t("cvEditorDensity")} + } + {t("cvEditorDocumentLanguage")} + {t("cvEditorDateFormat")} + + {supports("layout") && {t("cvEditorColumns")}} + {supports("spacing") && <> + update({ pageMarginMm: value })} /> + update({ sectionGapMm: value })} /> + update({ entryGapMm: value })} /> + } + {supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && + {t("cvEditorSidebarContent")} + update({ sidebarWidthMm: value })} /> + {["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => toggleSidebarSection(key)} />} label={key === "contact" ? t("cvEditorContactDetails") : sectionLabel(key, SECTION_LABELS[key] ?? key)} />)} + } + + {supports("photo") && update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />} + {supports("icons") && update({ showIcons: event.target.checked })} />} label={t("cvEditorContactIcons")} />} + } + + ); +} + +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 {label}{Number(value.toFixed(2))}{suffix} onChange(next as number)} />; +} diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index db894dc..8e163b1 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -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 === 0 && } + {tab === 0 && sectionName(key, fallback, t)} />} {tab === 1 && } - {tab === 2 && } + {tab === 2 && sectionName(key, fallback, t)} />} {tab === 3 && } {tab === 4 && } @@ -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) => 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 ( - - {mode === "template" && - {t("cvEditorTheme")} - {t("cvEditorThemeHelp")} - update(applyCvTheme(settings, theme.id))} /> - } - - {mode === "customize" && <> - {supports("accent") && <>{t("cvEditorColour")} - - {["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => ( - 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 } }} /> - ))} - - update({ accentColor: e.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} /> - - {settings.accentColor && } - } - - {supports("typography") && <>{t("cvEditorTypography")} - - {t("cvEditorHeadingFont")} - {t("cvEditorBodyFont")} - - update({ baseFontSizePt: value })} /> - update({ headingSizePt: value })} /> - update({ lineHeight: value })} />} - {supports("heading") && {t("cvEditorHeadingTreatment")}} - {supports("header") && {t("cvEditorHeaderTreatment")}} - {supports("skills") && <>{t("cvEditorSkillsPresentation")} - {settings.skillsStyle === "grouped" && `${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" && <> - {t("cvEditorDocument")} - - {supports("page") && <> - {t("cvEditorPageSize")} - {t("cvEditorDensity")} - } - {t("cvEditorDocumentLanguage")} - {t("cvEditorDateFormat")} - - {supports("layout") && {t("cvEditorColumns")}} - {supports("spacing") && <> - update({ pageMarginMm: value })} /> - update({ sectionGapMm: value })} /> - update({ entryGapMm: value })} /> - } - {supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && - {t("cvEditorSidebarContent")} - update({ sidebarWidthMm: value })} /> - {["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => toggleSidebarSection(key)} />} label={key === "contact" ? t("cvEditorContactDetails") : sectionName(key, SECTION_LABELS[key] ?? key, t)} />)} - } - - {supports("photo") && update({ showPhoto: e.target.checked })} />} label={t("cvEditorShowPhoto")} />} - {supports("icons") && update({ showIcons: e.target.checked })} />} label={t("cvEditorContactIcons")} />} - } - - ); -} - -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 {label}{Number(value.toFixed(2))}{suffix} onChange(next as number)} />; -} - function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) { const { language, t } = useI18n(); if (versions.length === 0) return {t("cvEditorNoVersions")};