feat(cv): harden multi-page builder
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user