fix: preserve blank CVs and reliable editor undo

This commit is contained in:
cesnimda
2026-10-27 07:37:03 +01:00
parent 5d4b8f23ce
commit 663023a088
22 changed files with 950 additions and 101 deletions
+115 -54
View File
@@ -2,7 +2,7 @@ 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, IconButton,
Alert, Box, Button, Checkbox, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton,
Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -36,7 +36,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
import {
CUSTOM_SECTION_PRESETS, CvAiCallbacks, CvAiInput, CvAiProposal, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
cvBuilderApi, getCvExportFailureKey, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
} from "../cvBuilder";
import { previewDocumentHtml } from "../cvPreview";
import { useDialogActions } from "../dialogs";
@@ -46,6 +46,11 @@ import ViewStateNotice from "../components/ViewStateNotice";
const MIN_PREVIEW_ZOOM = 0.2;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
type EditorSnapshot = { name: string; settings: CvVariantSettings };
function isTextInput(element: Element | null) {
return !!element?.matches('textarea,input:not([type=checkbox]):not([type=radio]):not([type=range]),[contenteditable=true]');
}
export default function CvBuilderEditor() {
const { id } = useParams();
@@ -101,15 +106,27 @@ export default function CvBuilderEditor() {
const loadRegion = useRef<HTMLDivElement | null>(null);
const retryFocus = useRef(false);
const blockerPromptOpen = useRef(false);
const undoStack = useRef<CvVariantSettings[]>([]);
const redoStack = useRef<CvVariantSettings[]>([]);
const undoStack = useRef<EditorSnapshot[]>([]);
const redoStack = useRef<EditorSnapshot[]>([]);
const typingGroup = useRef<{ element: Element; time: number } | null>(null);
const loadedVariantId = useRef<number | null>(null);
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
const editorReady = settings !== null;
const editorReady = settings !== null && loadedVariantId.current === variantId && !opening;
const previewBusy = previewing || (!previewError && (!html || measuredHtml !== html));
const previewReady = !!html && measuredHtml === html && !previewBusy && !previewError;
useEffect(() => {
let alive = true;
loadedVariantId.current = null;
latestSettings.current = null;
if (saveTimer.current) clearTimeout(saveTimer.current);
if (previewTimer.current) clearTimeout(previewTimer.current);
previewRequest.current += 1;
setSettings(null);
setHtml("");
setMeasuredHtml(null);
setVersions([]);
setSaveState("idle");
setOpening(true);
(async () => {
try {
@@ -132,6 +149,8 @@ export default function CvBuilderEditor() {
return () => {
alive = false;
editorEpoch.current += 1;
previewRequest.current += 1;
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [variantId, loadAttempt]);
@@ -148,6 +167,7 @@ export default function CvBuilderEditor() {
editorEpoch.current += 1;
serverVersion.current = variant.version;
revisionConflict.current = false;
loadedVariantId.current = variant.id;
setName(variant.name);
setSettings(variant.settings);
latestName.current = variant.name;
@@ -158,12 +178,13 @@ export default function CvBuilderEditor() {
setSaveState("saved");
undoStack.current = [];
redoStack.current = [];
typingGroup.current = null;
setHistoryRevision((value) => value + 1);
};
// Debounced live preview.
useEffect(() => {
if (!settings) return;
if (!editorReady || !settings) return;
const request = ++previewRequest.current;
setPreviewing(true);
if (previewTimer.current) clearTimeout(previewTimer.current);
@@ -183,7 +204,7 @@ export default function CvBuilderEditor() {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
};
}, [settings, previewRevision]);
}, [settings, previewRevision, editorReady]);
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
const epoch = editorEpoch.current;
@@ -260,7 +281,7 @@ export default function CvBuilderEditor() {
const acceptAi = async (proposal: CvAiProposal, result?: string) => {
const { epoch, revision } = await flushAiSource();
const previous = latestSettings.current!;
const previous = { name: latestName.current, settings: latestSettings.current! };
const apply = async () => {
if (epoch !== editorEpoch.current || revision !== saveRevision.current || revisionConflict.current) throw new Error(t("cvAiFailed"));
try {
@@ -274,6 +295,7 @@ export default function CvBuilderEditor() {
}
undoStack.current = [...undoStack.current.slice(-59), previous];
redoStack.current = [];
typingGroup.current = null;
saveRevision.current += 1;
persistedRevision.current = saveRevision.current;
latestSettings.current = saved.settings;
@@ -299,46 +321,67 @@ export default function CvBuilderEditor() {
await queued;
};
const editDocument = (next: CvVariantSettings, nextName = latestName.current) => {
const previous = latestSettings.current;
if (!editorReady || !previous || (nextName === latestName.current && JSON.stringify(next) === JSON.stringify(previous))) return;
const element = document.activeElement;
const time = Date.now();
const grouped = isTextInput(element) && typingGroup.current?.element === element && time - typingGroup.current.time < 1000;
if (!grouped) undoStack.current = [...undoStack.current.slice(-59), { name: latestName.current, settings: previous }];
typingGroup.current = isTextInput(element) ? { element: element!, time } : null;
redoStack.current = [];
setSettings(next);
setName(nextName);
scheduleSave(next, nextName);
setHistoryRevision(value => value + 1);
};
const update = (patch: Partial<CvVariantSettings>) => {
setSettings((prev) => {
if (!prev) return prev;
const next = { ...prev, ...patch };
undoStack.current = [...undoStack.current.slice(-59), prev];
redoStack.current = [];
setHistoryRevision((value) => value + 1);
scheduleSave(next);
return next;
});
if (latestSettings.current) editDocument({ ...latestSettings.current, ...patch });
};
const undo = () => {
setSettings((current) => {
const previous = undoStack.current.pop();
if (!current || !previous) return current;
redoStack.current.push(current);
setHistoryRevision((value) => value + 1);
scheduleSave(previous);
return previous;
});
if (!editorReady || revisionConflict.current || !latestSettings.current) return;
const previous = undoStack.current.pop();
if (!previous) return;
redoStack.current.push({ name: latestName.current, settings: latestSettings.current });
typingGroup.current = null;
setSettings(previous.settings);
setName(previous.name);
scheduleSave(previous.settings, previous.name);
setHistoryRevision(value => value + 1);
};
const redo = () => {
setSettings((current) => {
const next = redoStack.current.pop();
if (!current || !next) return current;
undoStack.current.push(current);
setHistoryRevision((value) => value + 1);
scheduleSave(next);
return next;
});
if (!editorReady || revisionConflict.current || !latestSettings.current) return;
const next = redoStack.current.pop();
if (!next) return;
undoStack.current.push({ name: latestName.current, settings: latestSettings.current });
typingGroup.current = null;
setSettings(next.settings);
setName(next.name);
scheduleSave(next.settings, next.name);
setHistoryRevision(value => value + 1);
};
const renameVariant = (value: string) => {
setName(value);
latestName.current = value;
if (settings) scheduleSave(settings, value);
if (latestSettings.current) editDocument(latestSettings.current, value);
};
useEffect(() => {
const shortcut = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : document.activeElement;
if (event.defaultPrevented || event.isComposing || event.altKey || !(event.ctrlKey || event.metaKey)
|| target?.closest('input,textarea,select,[contenteditable=true]') || !editorReady || revisionConflict.current) return;
const key = event.key.toLowerCase();
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
if (wantsRedo && redoStack.current.length) { event.preventDefault(); redo(); }
else if (key === "z" && !event.shiftKey && undoStack.current.length) { event.preventDefault(); undo(); }
};
document.addEventListener("keydown", shortcut);
return () => document.removeEventListener("keydown", shortcut);
});
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error" || saveState === "conflict";
const downloadUnsavedCopy = () => {
@@ -353,10 +396,11 @@ export default function CvBuilderEditor() {
const reloadSavedVariant = async () => {
if (!(await confirmAction(t("cvEditorConflictReloadMessage"), { title: t("cvEditorConflictReload"), confirmLabel: t("cvEditorConflictReload"), destructive: true }))) return;
const revision = saveRevision.current;
const epoch = editorEpoch.current;
await saveQueue.current;
try {
const saved = await cvBuilderApi.get(variantId);
if (saveRevision.current === revision) applyVariant(saved);
if (editorEpoch.current === epoch && saveRevision.current === revision) applyVariant(saved);
}
catch (error) { toast(getApiErrorMessage(error, t("cvEditorOpenFailed")), "error"); }
};
@@ -438,8 +482,8 @@ export default function CvBuilderEditor() {
a.click();
URL.revokeObjectURL(url);
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
const key = await getCvExportFailureKey(err);
toast(key ? t(key) : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
} finally {
exportInFlight.current = false;
setExporting(false);
@@ -461,26 +505,32 @@ export default function CvBuilderEditor() {
};
const loadVersions = async () => {
const epoch = editorEpoch.current;
try {
setVersions(await cvBuilderApi.versions(variantId));
const result = await cvBuilderApi.versions(variantId);
if (epoch === editorEpoch.current) setVersions(result);
} catch (err) {
toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
if (epoch === editorEpoch.current) toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
}
};
const restore = async (version: number) => {
const epoch = editorEpoch.current;
if (!(await confirmAction(t("cvEditorRestoreMessage", { version }), {
title: t("cvEditorRestoreTitle", { version }),
confirmLabel: t("cvEditorRestoreVersion"),
}))) return;
if (epoch !== editorEpoch.current) return;
if (hasUnsavedChanges && !(await retrySave())) {
toast(t("cvEditorSaveBeforeRestore"), "error");
return;
}
try {
await saveQueue.current;
if (epoch !== editorEpoch.current) return;
const revision = saveRevision.current;
const updated = await cvBuilderApi.restore(variantId, version, serverVersion.current);
if (epoch !== editorEpoch.current) return;
if (saveRevision.current === revision) applyVariant(updated);
else {
// Edits made while restore was in flight remain recoverable; never replace them on response.
@@ -490,6 +540,7 @@ export default function CvBuilderEditor() {
await loadVersions();
toast(t("cvEditorRestoredVersion", { version }), "success");
} catch (err) {
if (epoch !== editorEpoch.current) return;
const code = (err as { response?: { data?: { code?: string } } })?.response?.data?.code;
if (code === "cv_revision_conflict" || code === "cv_revision_required") {
revisionConflict.current = true;
@@ -550,13 +601,14 @@ export default function CvBuilderEditor() {
</Box>
);
}
if (!settings) return <EditorSkeleton />;
if (!settings || !editorReady) return <EditorSkeleton />;
const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
const canUndo = !revisionConflict.current && historyRevision >= 0 && undoStack.current.length > 0;
const canRedo = !revisionConflict.current && historyRevision >= 0 && redoStack.current.length > 0;
return (
<Stack spacing={1.5} data-content-density="comfortable" sx={{
<Stack spacing={1.5} data-content-density="comfortable" onBlurCapture={() => { typingGroup.current = null; }}
onPointerDownCapture={event => { if ((event.target as Element).closest('button,[role=button]')) typingGroup.current = null; }} sx={{
minWidth: 0,
"--cv-pane-top": "calc(var(--app-shell-header-height, 76px) + 12px)",
"& [tabindex], & button, & input, & textarea, & [role=combobox]": { scrollMarginTop: "var(--cv-pane-top)" },
@@ -612,6 +664,7 @@ export default function CvBuilderEditor() {
<Tooltip describeChild title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
</Stack>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ px: 1.5 }}>{t("cvEditorUndoSessionHelp")}</Typography>
<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>
@@ -755,12 +808,13 @@ type DocumentAiBlock = {
function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null): DocumentAiBlock[] {
const blocks: DocumentAiBlock[] = [];
const configured = new Map(settings.sections.map((section) => [section.key, section]));
if ((settings.headline ?? outline?.headline)?.trim()) {
blocks.push({ id: "headline", items: [(settings.headline ?? outline?.headline ?? "").trim()] });
const headline = settings.headline ?? (settings.includeProfileContact !== false ? outline?.headline : null);
if (headline?.trim()) {
blocks.push({ id: "headline", items: [headline.trim()] });
}
for (const section of outline?.sections ?? []) {
const sectionSetting = configured.get(section.key);
if (sectionSetting?.hidden) continue;
if (sectionSetting?.hidden || (settings.sectionsExplicit && !sectionSetting)) continue;
const title = sectionSetting?.title ?? section.title;
if (section.kind === "entries") {
const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`)));
@@ -786,6 +840,7 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null
}
}
for (const section of settings.customSections ?? []) {
if (settings.sectionsExplicit && !configured.has(`custom:${section.key}`)) continue;
if (!(configured.get(`custom:${section.key}`)?.hidden ?? section.hidden)) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
}
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
@@ -982,8 +1037,9 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
const [reorderMessage, setReorderMessage] = useState("");
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
// Full section list = configured order (once touched) else default, always including every known key.
// Legacy documents inherit profile sections; new explicit documents include only chosen keys.
const sectionRows: CvSectionSetting[] = useMemo(() => {
if (settings.sectionsExplicit) return settings.sections;
const populated = new Set((outline?.sections ?? []).map((section) => section.key));
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key, hidden: !populated.has(key) }));
const have = new Set(base.map((s) => s.key));
@@ -1000,7 +1056,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
}
}
return base;
}, [outline?.sections, settings.customSections, settings.sections]);
}, [outline?.sections, settings.customSections, settings.sections, settings.sectionsExplicit]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const reorderSections = (from: number, to: number, retainFocus = false) => {
@@ -1060,7 +1116,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
setAddContentOpen(false);
};
const addBuiltIn = (key: string) => {
patchSection(key, { hidden: false });
if (sectionRows.some(row => row.key === key)) patchSection(key, { hidden: false });
else writeSections([...sectionRows, { key, title: outlineByKey[key]?.title }]);
setAddContentOpen(false);
};
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
@@ -1081,7 +1138,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Stack spacing={2}>
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap">
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: "1 1 14rem", minWidth: 0 }}>
<TextField select fullWidth size="small" label={t("cvEditorGoToSection")}
<TextField select fullWidth size="small" disabled={!sectionRows.length} 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)}>
@@ -1094,6 +1151,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<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?section=profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
{t("cvEditorMasterProfileHelp")}
</Alert>
<FormControlLabel control={<Checkbox checked={settings.includeProfileContact !== false}
onChange={(_, checked) => update({ includeProfileContact: checked })} />} label={t("cvEditorIncludeProfileContact")} />
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
onChange={(e) => update({ headline: e.target.value || null })}
helperText={t("cvEditorHeadlineHelp")} />
@@ -1102,6 +1161,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Typography component="h3" variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
<Typography variant="caption" color="text.secondary" role="status" aria-label={t("cvEditorSectionOrderStatus")} sx={{ display: "block", minHeight: "1.5em" }}>{reorderMessage}</Typography>
{!sectionRows.length && <Alert severity="info">{t("cvEditorEmptyBody")}</Alert>}
<Stack spacing={0.5} sx={{ mt: 1 }}>
{sectionRows.map((row, i) => (
<SectionRow
@@ -1146,11 +1206,12 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("cvEditorAddContentHelp")}</Typography>
{settings.sectionsExplicit && <Alert severity="info" sx={{ mb: 2 }}>{t("cvEditorProfileContentHint")}</Alert>}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", md: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
{DEFAULT_SECTION_ORDER.map((key) => {
{Array.from(new Set([...DEFAULT_SECTION_ORDER, ...(outline?.sections ?? []).map(section => section.key)])).map((key) => {
const row = sectionRows.find((section) => section.key === key);
const added = !!row && !row.hidden;
const label = sectionName(key, SECTION_LABELS[key] ?? key, t);
const label = sectionName(key, outlineByKey[key]?.title ?? SECTION_LABELS[key] ?? key, t);
return <AddContentCard key={key} title={label} description={t("cvEditorAddBuiltInHelp", { section: label })} added={added} onAdd={() => addBuiltIn(key)} />;
})}
{CUSTOM_SECTION_PRESETS.map((preset) => {
+42 -6
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography,
Alert, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Menu, MenuItem, Paper, Radio, RadioGroup, Stack, TextField, Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
@@ -10,7 +10,7 @@ import PublicIcon from "@mui/icons-material/Public";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
@@ -45,6 +45,20 @@ export default function CvBuilderPage() {
const [newName, setNewName] = useState(t("cvDashboardUntitled"));
const [newTheme, setNewTheme] = useState("modern");
const [creating, setCreating] = useState(false);
const [contentSource, setContentSource] = useState("blank");
const [profileOutline, setProfileOutline] = useState<CvOutline | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const [profileFailed, setProfileFailed] = useState(false);
const [selectedSections, setSelectedSections] = useState<string[]>([]);
const [includeContact, setIncludeContact] = useState(false);
const loadProfile = async () => {
setProfileLoading(true);
setProfileFailed(false);
try { setProfileOutline(await cvBuilderApi.outline()); }
catch { setProfileFailed(true); }
finally { setProfileLoading(false); }
};
useEffect(() => {
if (pending) contentRef.current?.focus();
@@ -77,7 +91,12 @@ export default function CvBuilderPage() {
setCreateError(null);
setCreating(true);
try {
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: {
...emptyCvVariantSettings(newTheme),
sectionsExplicit: true,
includeProfileContact: contentSource === "profile" && includeContact,
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],
} });
setCreateOpen(false);
navigate(`/career/builder/${variant.id}`);
} catch (err) {
@@ -144,8 +163,8 @@ export default function CvBuilderPage() {
link.click();
URL.revokeObjectURL(url);
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" });
const key = await getCvExportFailureKey(err);
setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
} finally { finishAction(); }
};
@@ -216,10 +235,27 @@ export default function CvBuilderPage() {
<Typography id="create-cv-help" color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
{createError && <Alert id="create-cv-error" severity="error" sx={{ mb: 2 }}>{getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}</Alert>}
<TextField autoFocus fullWidth disabled={creating} required label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
<RadioGroup aria-label={t("cvDashboardContentSource")} value={contentSource} onChange={(event) => {
setContentSource(event.target.value);
if (event.target.value === "profile" && !profileOutline && !profileLoading) void loadProfile();
}}>
<FormControlLabel value="blank" control={<Radio disabled={creating} />} label={t("cvDashboardStartBlank")} />
<FormControlLabel value="profile" control={<Radio disabled={creating} />} label={t("cvDashboardFromProfile")} />
</RadioGroup>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t(contentSource === "profile" ? "cvDashboardProfileSelectionHelp" : "cvDashboardBlankHelp")}</Typography>
{contentSource === "profile" && <Stack sx={{ mb: 2 }}>
{profileLoading && <Typography role="status">{t("loading")}</Typography>}
{profileFailed && <Alert severity="error" action={<Button onClick={() => void loadProfile()}>{t("retry")}</Button>}>{t("cvDashboardProfileLoadFailed")}</Alert>}
{profileOutline && <>
<FormControlLabel control={<Checkbox disabled={creating} checked={includeContact} onChange={(event) => setIncludeContact(event.target.checked)} />} label={t("cvEditorIncludeProfileContact")} />
{profileOutline.sections.length === 0 && <Typography>{t("cvDashboardProfileEmpty")}</Typography>}
{profileOutline.sections.map((section) => <FormControlLabel key={section.key} label={section.title} control={<Checkbox disabled={creating} checked={selectedSections.includes(section.key)} onChange={(event) => setSelectedSections((previous) => event.target.checked ? [...previous, section.key] : previous.filter((key) => key !== section.key))} />} />)}
</>}
</Stack>}
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
<CvTemplateGallery disabled={creating} themes={themes.length ? themes : [{ id: "modern", name: t("cvDashboardDefaultTemplate"), category: t("cvEditorTemplate"), description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} />
</DialogContent>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim() || (contentSource === "profile" && (!profileOutline || profileLoading))} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
</Dialog>
</Box>
);