feat(cv): harden multi-page builder
CI and Deploy / test (pull_request) Failing after 3m0s
CI and Deploy / deploy (pull_request) Has been skipped

Wrap pathological content, paginate oversized entries, measure A4 and Letter previews correctly, unify section ordering, and gate stored-output actions on saved state.
This commit is contained in:
cesnimda
2026-08-15 14:09:51 +02:00
parent d424633f95
commit 5203ddea72
19 changed files with 508 additions and 206 deletions
+152 -76
View File
@@ -28,7 +28,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
import {
AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, moveItem,
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
} from "../cvBuilder";
import { useAccountPlan } from "../accountPlan";
import { useDialogActions } from "../dialogs";
@@ -42,7 +42,7 @@ const FONTS = [
"'Poppins', 'Segoe UI', Arial, sans-serif",
];
const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"];
const A4_PAGE_PX = (297 / 25.4) * 96; // one A4 page height in CSS px at 96dpi
const MIN_PREVIEW_ZOOM = 0.32;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
export default function CvBuilderEditor() {
@@ -64,21 +64,27 @@ export default function CvBuilderEditor() {
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [previewRevision, setPreviewRevision] = useState(0);
const [previewHeight, setPreviewHeight] = useState(() => getCvPageMetrics("a4").heightPx);
const [previewOverflow, setPreviewOverflow] = useState(false);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(1);
const [saveState, setSaveState] = useState<SaveState>("idle");
const [exporting, setExporting] = useState(false);
const [publishing, setPublishing] = useState(false);
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveRevision = useRef(0);
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
const latestSettings = useRef<CvVariantSettings | null>(null);
const latestName = useRef("");
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewRequest = useRef(0);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const contentHeight = useRef(A4_PAGE_PX);
const blockerPromptOpen = useRef(false);
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
useEffect(() => {
let alive = true;
@@ -118,17 +124,20 @@ export default function CvBuilderEditor() {
// Debounced live preview.
useEffect(() => {
if (!settings) return;
const request = ++previewRequest.current;
setPreviewing(true);
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(async () => {
try {
const render = await cvBuilderApi.previewSettings(settings);
if (previewRequest.current !== request) return;
setHtml(render.html);
setPreviewError(false);
} catch {
if (previewRequest.current !== request) return;
setPreviewError(true);
} finally {
setPreviewing(false);
if (previewRequest.current === request) setPreviewing(false);
}
}, 300);
return () => {
@@ -137,15 +146,20 @@ export default function CvBuilderEditor() {
}, [settings, previewRevision]);
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
setSaveState("saving");
try {
await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" });
if (saveRevision.current === revision) setSaveState("saved");
return true;
} catch {
if (saveRevision.current === revision) setSaveState("error");
return false;
}
const save = async () => {
if (saveRevision.current === revision) setSaveState("saving");
try {
await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" });
if (saveRevision.current === revision) setSaveState("saved");
return true;
} catch {
if (saveRevision.current === revision) setSaveState("error");
return false;
}
};
const queued = saveQueue.current.then(save, save);
saveQueue.current = queued;
return queued;
}, [variantId]);
const scheduleSave = useCallback(
@@ -221,6 +235,11 @@ export default function CvBuilderEditor() {
}, []);
const togglePublic = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
toast("Save the current CV before changing its public link.", "error");
return;
}
setPublishing(true);
try {
const updated = await cvBuilderApi.setPublic(variantId, !isPublic);
setIsPublic(updated.isPublic);
@@ -228,6 +247,8 @@ export default function CvBuilderEditor() {
toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success");
} catch (err) {
toast(getApiErrorMessage(err, "Could not change visibility."), "error");
} finally {
setPublishing(false);
}
};
@@ -237,6 +258,11 @@ export default function CvBuilderEditor() {
};
const exportPdf = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
toast("Save the current CV before exporting it.", "error");
return;
}
setExporting(true);
try {
const res = await api.post(cvBuilderApi.exportPdfUrl(variantId), {}, { responseType: "blob" });
const url = URL.createObjectURL(res.data as Blob);
@@ -247,6 +273,8 @@ export default function CvBuilderEditor() {
URL.revokeObjectURL(url);
} catch (err) {
toast(getApiErrorMessage(err, "PDF export failed."), "error");
} finally {
setExporting(false);
}
};
@@ -259,6 +287,14 @@ export default function CvBuilderEditor() {
};
const restore = async (version: number) => {
if (!(await confirmAction(`Restore version ${version}? Your current saved CV remains in the version history.`, {
title: `Restore version ${version}`,
confirmLabel: "Restore version",
}))) return;
if (hasUnsavedChanges && !(await retrySave())) {
toast("Save the current CV before restoring an older version.", "error");
return;
}
try {
const updated = await cvBuilderApi.restore(variantId, version);
applyVariant(updated);
@@ -273,19 +309,33 @@ export default function CvBuilderEditor() {
const onIframeLoad = () => {
try {
const doc = iframeRef.current?.contentDocument;
const h = doc?.body?.scrollHeight ?? A4_PAGE_PX;
contentHeight.current = h;
if (iframeRef.current) iframeRef.current.style.height = `${h}px`;
setPages(Math.max(1, Math.round(h / A4_PAGE_PX)));
const h = Math.max(
pageMetrics.heightPx,
doc?.body?.scrollHeight ?? 0,
doc?.documentElement?.scrollHeight ?? 0,
);
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
setPreviewHeight(h);
setPages(pageCount);
setPage((current) => Math.min(current, pageCount));
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0);
setPreviewOverflow(contentWidth > viewportWidth + 2);
} catch {
setPages(1);
setPreviewOverflow(false);
}
};
const goToPage = (p: number) => {
const clamped = Math.min(Math.max(1, p), pages);
setPage(clamped);
scrollRef.current?.scrollTo({ top: (clamped - 1) * A4_PAGE_PX * zoom, behavior: "smooth" });
scrollRef.current?.scrollTo({ top: (clamped - 1) * pageMetrics.heightPx * zoom, behavior: "smooth" });
};
const fitPreview = () => {
const availableWidth = Math.max(1, (scrollRef.current?.clientWidth ?? pageMetrics.widthPx) - 24);
setZoom(Math.min(1, Math.max(MIN_PREVIEW_ZOOM, availableWidth / pageMetrics.widthPx)));
};
if (loadError) {
@@ -300,7 +350,7 @@ export default function CvBuilderEditor() {
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12 }}>
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Tooltip title="Back to CVs"><IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
@@ -309,14 +359,14 @@ export default function CvBuilderEditor() {
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} onClick={exportPdf}>PDF</Button>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} onClick={togglePublic}>
{isPublic ? "Public" : "Private"}
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Export PDF"}</Button>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
{publishing ? "Updating…" : isPublic ? "Public" : "Private"}
</Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
</Stack>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="fullWidth" sx={{ mb: 1.5 }}>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5 }}>
<Tab label="Content" />
<Tab label="Customize" />
<Tab label="AI Tools" />
@@ -329,43 +379,45 @@ export default function CvBuilderEditor() {
{tab === 3 && <HistoryTab versions={versions} onRestore={restore} />}
</Paper>
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "#e9edf2" }}>
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
{previewError && <Chip size="small" label="Preview unavailable" color="error" variant="outlined" />}
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>Retry preview</Button>}
<Box sx={{ flex: 1 }} />
{pages > 1 && (
<Stack direction="row" alignItems="center" spacing={0.5}>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button>
<Typography variant="caption">Page {page}/{pages}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button>
</Stack>
)}
{pages >= 3 && <Chip size="small" color="warning" variant="outlined" label={`${pages}-page CV`} />}
<Stack direction="row" alignItems="center" spacing={0.5}>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button>
<Typography variant="caption">Page {page} of {pages}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button>
</Stack>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<IconButton size="small" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(0.4, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
<Slider size="small" value={zoom} min={0.4} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" />
<IconButton size="small" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" />
<IconButton size="small" aria-label="Zoom in" onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton>
<Button size="small" onClick={() => setZoom(0.62)}>Fit</Button>
<Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
<Button size="small" onClick={fitPreview}>Fit</Button>
</Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
<Box sx={{ position: "relative", width: `calc(210mm * ${zoom})`, height: `calc(${contentHeight.current}px * ${zoom})`, flex: "0 0 auto" }}>
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `${previewHeight * zoom}px`, flex: "0 0 auto" }}>
<iframe
ref={iframeRef}
title="CV preview"
srcDoc={html}
onLoad={onIframeLoad}
style={{
width: "210mm", height: `${contentHeight.current}px`, border: "none",
width: `${pageMetrics.widthMm}mm`, height: `${previewHeight}px`, border: "none",
transform: `scale(${zoom})`, transformOrigin: "top left",
boxShadow: "0 8px 30px rgba(0,0,0,0.18)", background: "#fff", display: "block",
boxShadow: "0 8px 30px rgba(0,0,0,0.24)", background: "#fff", display: "block",
}}
/>
{Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => (
<Box key={i} aria-hidden sx={{
position: "absolute", left: 0, right: 0, top: `calc(${(i + 1) * A4_PAGE_PX}px * ${zoom})`,
borderTop: "2px dashed rgba(220,38,38,0.55)", pointerEvents: "none",
position: "absolute", left: 0, right: 0, top: `${(i + 1) * pageMetrics.heightPx * zoom}px`,
borderTop: "2px dashed", borderColor: "error.main", opacity: 0.72, pointerEvents: "none",
}} />
))}
</Box>
@@ -384,7 +436,7 @@ function EditorSkeleton() {
<Skeleton variant="rounded" height={44} sx={{ mt: 2 }} />
{[0, 1, 2, 3, 4].map((i) => <Skeleton key={i} variant="rounded" height={40} sx={{ mt: 1 }} />)}
</Paper>
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "#e9edf2", display: "flex", justifyContent: "center" }}>
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", display: "flex", justifyContent: "center" }}>
<Skeleton variant="rounded" width="70%" height={620} />
</Paper>
</Box>
@@ -421,11 +473,18 @@ function ContentTab({ settings, update, outline }: {
const { confirmAction } = useDialogActions();
// Full section list = configured order (once touched) else default, always including every known key.
const sectionRows: CvSectionSetting[] = useMemo(() => {
const base = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
const have = new Set(base.map((s) => s.key));
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key });
for (const custom of settings.customSections) {
const key = `custom:${custom.key}`;
if (!have.has(key)) {
base.push({ key, hidden: custom.hidden });
have.add(key);
}
}
return base;
}, [settings.sections]);
}, [settings.customSections, settings.sections]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
@@ -439,9 +498,22 @@ function ContentTab({ settings, update, outline }: {
return m;
}, [outline]);
const customBySectionKey = useMemo(() => Object.fromEntries(
settings.customSections.map((section) => [`custom:${section.key}`, section]),
), [settings.customSections]);
const orderedCustomSections = useMemo(() => {
const rank = new Map(sectionRows.map((section, index) => [section.key, index]));
return [...settings.customSections].sort((a, b) =>
(rank.get(`custom:${a.key}`) ?? Number.MAX_SAFE_INTEGER) - (rank.get(`custom:${b.key}`) ?? Number.MAX_SAFE_INTEGER));
}, [sectionRows, settings.customSections]);
const addCustom = () => {
const key = `c${Date.now().toString(36)}`;
update({ customSections: [...settings.customSections, { key, title: "New section", items: [] }] });
update({
customSections: [...settings.customSections, { key, title: "New section", items: [] }],
sections: [...sectionRows, { key: `custom:${key}` }],
});
};
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
@@ -451,10 +523,11 @@ function ContentTab({ settings, update, outline }: {
confirmLabel: "Delete section",
destructive: true,
}))) return;
update({ customSections: settings.customSections.filter((c) => c.key !== section.key) });
update({
customSections: settings.customSections.filter((c) => c.key !== section.key),
sections: sectionRows.filter((row) => row.key !== `custom:${section.key}`),
});
};
const moveCustom = (index: number, delta: number) =>
update({ customSections: moveItem(settings.customSections, index, index + delta) });
const updateCustomItem = (key: string, index: number, value: string) => {
const section = settings.customSections.find((item) => item.key === key);
if (!section) return;
@@ -490,9 +563,14 @@ function ContentTab({ settings, update, outline }: {
dragging={sectionDrag.dragIndex === i}
over={sectionDrag.overIndex === i && sectionDrag.dragIndex !== i}
outlineSection={outlineByKey[row.key]}
customSection={customBySectionKey[row.key]}
settings={settings}
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
onPatch={(p) => patchSection(row.key, p)}
onRenameCustom={(title) => {
const custom = customBySectionKey[row.key];
if (custom) updateCustom(custom.key, { title });
}}
onUpdateSettings={update}
/>
))}
@@ -509,39 +587,35 @@ function ContentTab({ settings, update, outline }: {
Add sections unique to this CV (e.g. a portfolio note) without changing your master profile.
</Typography>
)}
{settings.customSections.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
Reorder and show or hide custom sections in the section list above.
</Typography>
)}
<Stack spacing={1} sx={{ mt: 1 }}>
{settings.customSections.map((c, sectionIndex) => (
{orderedCustomSections.map((c) => (
<Paper key={c.key} variant="outlined" sx={{ p: 1 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<TextField variant="standard" fullWidth value={c.title ?? ""} placeholder="Section title"
error={!c.title?.trim()} helperText={!c.title?.trim() ? "Enter a section title." : undefined}
onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} />
<IconButton size="small" aria-label="Move custom section up" disabled={sectionIndex === 0} onClick={() => moveCustom(sectionIndex, -1)}><ArrowUpwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label="Move custom section down" disabled={sectionIndex === settings.customSections.length - 1} onClick={() => moveCustom(sectionIndex, 1)}><ArrowDownwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={c.hidden ? "Show custom section" : "Hide custom section"} onClick={() => updateCustom(c.key, { hidden: !c.hidden })}>
{c.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
<IconButton size="small" aria-label="Remove custom section" onClick={() => void removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack>
{!c.hidden ? (
<Stack spacing={1} sx={{ mt: 1 }}>
{c.items.map((item, itemIndex) => (
<Stack key={`${c.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item}
error={!item.trim()} helperText={!item.trim() ? "Enter content or delete this entry." : undefined}
onChange={(event) => updateCustomItem(c.key, itemIndex, event.target.value)} />
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0}
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1}
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack>
))}
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button>
</Stack>
) : (
<Typography variant="caption" color="text.secondary">Hidden from this CV.</Typography>
)}
<Stack spacing={1} sx={{ mt: 1 }}>
{c.items.map((item, itemIndex) => (
<Stack key={`${c.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item}
error={!item.trim()} helperText={!item.trim() ? "Enter content or delete this entry." : undefined}
onChange={(event) => updateCustomItem(c.key, itemIndex, event.target.value)} />
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0}
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1}
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack>
))}
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button>
</Stack>
</Paper>
))}
</Stack>
@@ -551,7 +625,7 @@ function ContentTab({ settings, update, outline }: {
}
function SectionRow({
row, index, total, dragProps, dragging, over, outlineSection, settings, onMove, onPatch, onUpdateSettings,
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateSettings,
}: {
row: CvSectionSetting;
index: number;
@@ -560,14 +634,16 @@ function SectionRow({
dragging: boolean;
over: boolean;
outlineSection?: CvOutlineSection;
customSection?: CvCustomSectionSetting;
settings: CvVariantSettings;
onMove: (delta: number) => void;
onPatch: (patch: Partial<CvSectionSetting>) => void;
onRenameCustom: (title: string) => void;
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
}) {
const [expanded, setExpanded] = useState(false);
const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0;
const sectionLabel = row.title ?? SECTION_LABELS[row.key] ?? row.key;
const sectionLabel = customSection?.title ?? row.title ?? SECTION_LABELS[row.key] ?? row.key;
return (
<Paper
@@ -585,9 +661,10 @@ function SectionRow({
<IconButton size="small" aria-label={`Move ${sectionLabel} section up`} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
<IconButton size="small" aria-label={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
</Stack>
<TextField variant="standard" fullWidth value={row.title ?? SECTION_LABELS[row.key] ?? row.key}
onChange={(e) => onPatch({ title: e.target.value })}
<TextField variant="standard" fullWidth value={sectionLabel}
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
{customSection && <Chip size="small" label="Custom" variant="outlined" sx={{ height: 20 }} />}
{editable && (
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel} entries`} aria-expanded={expanded} onClick={() => setExpanded((e) => !e)}
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
@@ -757,7 +834,6 @@ function CustomizeTab({ settings, update, themes }: {
<Divider />
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" />
<FormControlLabel control={<Switch checked={settings.showPageNumbers} onChange={(e) => update({ showPageNumbers: e.target.checked })} />} label="Page numbers" />
</Stack>
);
}
+10 -1
View File
@@ -12,10 +12,12 @@ import PublicIcon from "@mui/icons-material/Public";
import { getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
export default function CvBuilderPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -56,6 +58,13 @@ export default function CvBuilderPage() {
};
const remove = async (id: number) => {
const variant = variants.find((item) => item.id === id);
setMenu(null);
if (!(await confirmAction(`Delete "${variant?.name ?? "this CV"}" and its saved version history?`, {
title: "Delete CV",
confirmLabel: "Delete CV",
destructive: true,
}))) return;
try {
await cvBuilderApi.remove(id);
setVariants((v) => v.filter((x) => x.id !== id));
@@ -93,7 +102,7 @@ export default function CvBuilderPage() {
<Paper key={v.id} sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 } }} onClick={() => navigate(`/career/builder/${v.id}`)}>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
<IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
<MoreVertIcon fontSize="small" />
</IconButton>
</Stack>