feat(cv): redesign editor frame and section navigation

This commit is contained in:
cesnimda
2026-10-07 12:42:16 +02:00
parent efc9e7bdd4
commit 44b944e5ed
45 changed files with 19230 additions and 55 deletions
+110 -47
View File
@@ -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, IconButton,
MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, IconButton,
Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -20,6 +20,7 @@ import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import TranslateIcon from "@mui/icons-material/Translate";
import SpellcheckIcon from "@mui/icons-material/Spellcheck";
@@ -59,7 +60,9 @@ export default function CvBuilderEditor() {
const [outline, setOutline] = useState<CvOutline | null>(null);
const [isPublic, setIsPublic] = useState(false);
const [publicSlug, setPublicSlug] = useState("");
const [tab, setTab] = useState(0);
const [jobApplicationId, setJobApplicationId] = useState<number | null>(null);
const [actionsAnchor, setActionsAnchor] = useState<HTMLElement | null>(null);
const [tab, setTab] = useState(1);
const [html, setHtml] = useState("");
const [zoom, setZoom] = useState(0.62);
const [previewing, setPreviewing] = useState(false);
@@ -127,6 +130,7 @@ export default function CvBuilderEditor() {
latestSettings.current = variant.settings;
setIsPublic(variant.isPublic);
setPublicSlug(variant.publicSlug);
setJobApplicationId(variant.jobApplicationId ?? null);
setSaveState("saved");
undoStack.current = [];
redoStack.current = [];
@@ -456,47 +460,68 @@ export default function CvBuilderEditor() {
<Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button>
</Stack>
</Alert>}
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)}
error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined}
sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }}
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": t("cvEditorName") } }} />
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} />
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
{compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}>
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
</Stack>}
<Box sx={{ flex: { sm: 1 } }} />
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>{t("cvEditorDuplicate")}</Button>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}
</Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>{t("cvEditorCopyLink")}</Button>}
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
<Paper component="header" variant="outlined" sx={{ p: 1.5 }}>
<Stack direction={{ xs: "column", sm: "row" }} alignItems={{ sm: "center" }} gap={1.5}>
<Stack direction="row" alignItems="flex-start" gap={1} sx={{ flex: 1, minWidth: 0 }}>
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
<Box sx={{ flex: 1, minWidth: 0 }}>
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)} fullWidth
error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined}
sx={{ "& input": { typography: "sectionTitle" } }}
slotProps={{ htmlInput: { "aria-label": t("cvEditorName") } }} />
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">{t("cvEditorScope")}</Typography>
<Typography variant="caption" color="text.secondary">{t("cvEditorVersion", { version: serverVersion.current })}</Typography>
{jobApplicationId && <Button component={RouterLink} to={`/jobs/${jobApplicationId}`} size="small">{t("cvEditorLinkedApplication")}</Button>}
{isPublic && <Chip size="small" icon={<PublicIcon />} label={t("cvEditorShared")} variant="outlined" />}
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
</Stack>
</Box>
</Stack>
<Stack direction="row" alignItems="center" gap={0.5}>
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} sx={{ flex: { xs: 1, sm: "none" } }} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
<Tooltip title={t("cvEditorMoreActions")}><IconButton size="small" aria-label={t("cvEditorMoreActions")} aria-haspopup="menu" aria-expanded={!!actionsAnchor} aria-controls={actionsAnchor ? "cv-document-actions" : undefined} onClick={(event) => setActionsAnchor(event.currentTarget)}><MoreHorizIcon /></IconButton></Tooltip>
</Stack>
</Stack>
<Menu id="cv-document-actions" anchorEl={actionsAnchor} open={!!actionsAnchor} onClose={() => setActionsAnchor(null)} slotProps={{ list: { "aria-label": t("cvEditorMoreActions") } }}>
<MenuItem disabled={!name.trim()} onClick={() => { setActionsAnchor(null); void duplicateVariant(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorDuplicate")}</MenuItem>
<MenuItem disabled={publishing || exporting || !name.trim()} onClick={() => { setActionsAnchor(null); void togglePublic(); }}><PublicIcon fontSize="small" sx={{ mr: 1 }} />{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}</MenuItem>
{isPublic && <MenuItem onClick={() => { setActionsAnchor(null); copyPublicLink(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorCopyLink")}</MenuItem>}
</Menu>
{compactEditor && <Stack direction="row" gap={0.5} sx={{ mt: 1.5 }}>
<Button fullWidth size="small" aria-pressed={mobilePane === "edit"} variant={mobilePane === "edit" ? "contained" : "outlined"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
<Button fullWidth size="small" aria-pressed={mobilePane === "preview"} variant={mobilePane === "preview" ? "contained" : "outlined"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
</Stack>}
</Paper>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
<Tab label={t("cvEditorTemplate")} />
<Tab label={t("cvEditorContent")} />
<Tab label={t("cvEditorCustomize")} />
<Tab label={t("cvEditorAiTools")} />
<Tab label={t("cvEditorHistory")} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
<Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
<Stack direction="row">
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
</Stack>
</Stack>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
{[1, 0, 2, 3, 4].map((value) => <Tab key={value} value={value} id={`cv-studio-tab-${value}`} aria-controls={`cv-studio-panel-${value}`} label={t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])} />)}
</Tabs>
{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 && <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} />}
<Box sx={{ p: { xs: 1, sm: 1.5 } }}>
<Box role="tabpanel" id="cv-studio-panel-1" aria-labelledby="cv-studio-tab-1" hidden={tab !== 1}>
<ContentTab settings={settings} update={update} outline={outline} showPreviewEntry={!compactEditor} onFocusPreview={() => {
scrollRef.current?.focus({ preventScroll: true });
scrollRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
}} />
</Box>
{[0, 2, 3, 4].map((value) => <Box key={value} role="tabpanel" id={`cv-studio-panel-${value}`} aria-labelledby={`cv-studio-tab-${value}`} hidden={tab !== value}>
{tab === value && <>
{value === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
{value === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
{value === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
{value === 4 && <HistoryTab versions={versions} onRestore={restore} />}
</>}
</Box>)}
</Box>
</Paper>
<Box onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
@@ -562,8 +587,8 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
const m = map[state];
if (!m.label) return null;
return (
<Stack spacing={0.5} alignItems="flex-end" aria-live="polite">
<Chip size="small" label={m.label} color={m.color} variant="outlined" />
<Stack direction="row" gap={0.5} alignItems="center" aria-live="polite">
{state === "saved" ? <Typography variant="caption" color="text.secondary">{m.label}</Typography> : <Chip size="small" label={m.label} color={m.color} variant="outlined" />}
{(state === "unsaved" || state === "error") ? (
<Button size="small" disabled={!canRetry} onClick={onRetry}>{t("cvEditorSaveNow")}</Button>
) : null}
@@ -783,14 +808,19 @@ function presetLabel(key: string, fallback: string, t: Translate) {
return labels[key] ?? fallback;
}
function ContentTab({ settings, update, outline }: {
function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview }: {
settings: CvVariantSettings;
update: (p: Partial<CvVariantSettings>) => void;
outline: CvOutline | null;
showPreviewEntry: boolean;
onFocusPreview: () => void;
}) {
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [addContentOpen, setAddContentOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
// Full section list = configured order (once touched) else default, always including every known key.
const sectionRows: CvSectionSetting[] = useMemo(() => {
const populated = new Set((outline?.sections ?? []).map((section) => section.key));
@@ -813,6 +843,19 @@ function ContentTab({ settings, update, outline }: {
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
const toggleSection = (key: string) => setExpandedSections((previous) => {
const next = new Set(previous);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
const goToSection = (key: string) => {
setActiveSection(key);
setExpandedSections((previous) => new Set(previous).add(key));
requestAnimationFrame(() => {
sectionButtons.current[key]?.focus({ preventScroll: true });
sectionButtons.current[key]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
});
};
const patchSection = (key: string, patch: Partial<CvSectionSetting>) =>
writeSections(sectionRows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
@@ -862,7 +905,19 @@ function ContentTab({ settings, update, outline }: {
};
return (
<Stack spacing={2}>
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
<Stack direction="row" gap={1} alignItems="center">
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: 1, minWidth: 0 }}>
<TextField select fullWidth size="small" label={t("cvEditorGoToSection")}
value={sectionRows.some((row) => row.key === activeSection) ? activeSection : ""}
onChange={(event) => setActiveSection(event.target.value)}>
{sectionRows.map((row) => <MenuItem key={row.key} value={row.key} onClick={() => goToSection(row.key)}>
{customBySectionKey[row.key]?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t)} · {t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}
</MenuItem>)}
</TextField>
</Box>
{showPreviewEntry && <Button size="small" onClick={onFocusPreview}>{t("cvEditorFocusPreview")}</Button>}
</Stack>
<Alert severity="info" sx={{ flexWrap: "wrap", "& .MuiAlert-message": { flex: "1 1 12rem", minWidth: 0, overflowWrap: "anywhere" }, "& .MuiAlert-action": { ml: "auto" } }} action={<Button component={RouterLink} to="/career/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
{t("cvEditorMasterProfileHelp")}
</Alert>
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
@@ -885,6 +940,10 @@ function ContentTab({ settings, update, outline }: {
outlineSection={outlineByKey[row.key]}
customSection={customBySectionKey[row.key]}
settings={settings}
expanded={expandedSections.has(row.key)}
onToggle={() => toggleSection(row.key)}
onSectionFocus={() => setActiveSection(row.key)}
toggleRef={(button) => { sectionButtons.current[row.key] = button; }}
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
onPatch={(p) => patchSection(row.key, p)}
onRenameCustom={(title) => {
@@ -945,7 +1004,7 @@ function AddContentCard({ title, description, added, onAdd }: { title: string; d
}
function SectionRow({
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
}: {
row: CvSectionSetting;
index: number;
@@ -956,6 +1015,10 @@ function SectionRow({
outlineSection?: CvOutlineSection;
customSection?: CvCustomSectionSetting;
settings: CvVariantSettings;
expanded: boolean;
onToggle: () => void;
onSectionFocus: () => void;
toggleRef: (button: HTMLButtonElement | null) => void;
onMove: (delta: number) => void;
onPatch: (patch: Partial<CvSectionSetting>) => void;
onRenameCustom: (title: string) => void;
@@ -965,7 +1028,6 @@ function SectionRow({
}) {
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [expanded, setExpanded] = useState(false);
const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
const editableItems = row.items ?? sourceItems;
@@ -975,6 +1037,7 @@ function SectionRow({
<Paper
variant="outlined"
{...dragProps}
role="group" aria-label={sectionLabel} onFocusCapture={onSectionFocus}
sx={{
// Hidden in the document is not disabled in the editor: keep names and
// controls readable. The visibility icon/label still conveys inclusion.
@@ -1001,7 +1064,7 @@ function SectionRow({
if (customSection) onRemoveCustom();
else void confirmAction(t("cvEditorRemoveSectionMessage", { section: sectionLabel }), { title: t("cvEditorRemoveSectionTitle"), confirmLabel: t("cvEditorRemoveFromCv"), destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
}}><DeleteOutlineIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}
<IconButton ref={toggleRef} size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={onToggle}
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
<ExpandMoreIcon fontSize="small" />
</IconButton>