feat(cv): redesign document editing with revision-safe letters and writing tools

This commit is contained in:
cesnimda
2026-10-25 22:44:14 +01:00
parent 01e10996c5
commit 2bfaf6c14b
57 changed files with 2859 additions and 118 deletions
+44 -9
View File
@@ -90,6 +90,7 @@ export default function CvBuilderEditor() {
const serverVersion = useRef(0);
const editorEpoch = useRef(0);
const revisionConflict = useRef(false);
const exportInFlight = useRef(false);
const latestSettings = useRef<CvVariantSettings | null>(null);
const latestName = useRef("");
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -416,23 +417,31 @@ export default function CvBuilderEditor() {
};
const exportPdf = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
toast(t("cvEditorSaveBeforeExport"), "error");
return;
}
if (exportInFlight.current) return;
exportInFlight.current = true;
setExporting(true);
try {
const res = await api.post(cvBuilderApi.exportPdfUrl(variantId), {}, { responseType: "blob" });
let source: { epoch: number; revision: number };
try { source = await flushAiSource(); }
catch { toast(t("cvEditorSaveBeforeExport"), "error"); return; }
const fileName = latestName.current;
const res = await api.post(cvBuilderApi.exportPdfUrl(variantId), {}, { responseType: "blob", params: { expectedVersion: serverVersion.current } });
if (source.epoch !== editorEpoch.current) return;
if (source.revision !== saveRevision.current) {
toast(t("cvEditorExportDraftChanged"), "warning");
return;
}
const url = URL.createObjectURL(res.data as Blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name || "cv"}.pdf`;
a.download = `${fileName || "cv"}.pdf`;
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");
} finally {
exportInFlight.current = false;
setExporting(false);
}
};
@@ -569,6 +578,7 @@ export default function CvBuilderEditor() {
sx={{ "& input": { typography: "sectionTitle" } }}
slotProps={{ htmlInput: { "aria-label": t("cvEditorName") } }} />
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }}>
{!jobApplicationId && <Typography variant="caption" color="text.secondary">{t("cvEditorGeneralDocument")}</Typography>}
<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>}
@@ -641,6 +651,13 @@ export default function CvBuilderEditor() {
<Typography variant="caption" color="text.secondary" role="status">
{previewBusy ? t("cvEditorUpdatingPreview") : previewReady ? t("cvEditorPageCount", { count: pages }) : null}
</Typography>
{previewReady && pages > 1 && <TextField select size="small" label={t("cvEditorGoToPage")} value="" sx={{ minWidth: 140 }} onChange={(event) => {
const pane = scrollRef.current;
const page = pane?.querySelector<HTMLElement>(`[data-cv-page="${Number(event.target.value)}"]`);
if (pane && page) pane.scrollTop += page.getBoundingClientRect().top - pane.getBoundingClientRect().top;
}}>
{Array.from({ length: pages }, (_, index) => <MenuItem key={index} value={index + 1}>{t("cvEditorPageOf", { page: index + 1, pages })}</MenuItem>)}
</TextField>}
<Button size="small" onClick={() => {
setMobilePane("edit");
requestAnimationFrame(() => {
@@ -963,6 +980,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
const addContentButton = useRef<HTMLButtonElement | null>(null);
const [activeSection, setActiveSection] = useState<string | null>(null);
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.
const sectionRows: CvSectionSetting[] = useMemo(() => {
@@ -985,7 +1003,19 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
}, [outline?.sections, settings.customSections, settings.sections]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
const reorderSections = (from: number, to: number, retainFocus = false) => {
if (from === to || to < 0 || to >= sectionRows.length) return;
const row = sectionRows[from];
const item = settings.customSections.find(section => `custom:${section.key}` === row.key)?.title
?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
const focused = document.activeElement as HTMLButtonElement | null;
writeSections(moveItem(sectionRows, from, to));
setReorderMessage(t("cvEditorReordered", { item, position: to + 1, total: sectionRows.length }));
if (retainFocus) requestAnimationFrame(() => {
(focused?.disabled ? sectionButtons.current[row.key] : focused)?.focus({ preventScroll: true });
});
};
const sectionDrag = useDragReorder((from, to) => reorderSections(from, to));
const toggleSection = (key: string) => setExpandedSections((previous) => {
const next = new Set(previous);
if (next.has(key)) next.delete(key); else next.add(key);
@@ -1071,6 +1101,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Box>
<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>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{sectionRows.map((row, i) => (
<SectionRow
@@ -1088,7 +1119,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
onToggle={() => toggleSection(row.key)}
onSectionFocus={() => setActiveSection(row.key)}
toggleRef={(button) => { sectionButtons.current[row.key] = button; }}
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
onMove={(d) => reorderSections(i, i + d, true)}
onPatch={(p) => patchSection(row.key, p)}
onRenameCustom={(title) => {
const custom = customBySectionKey[row.key];
@@ -1220,6 +1251,9 @@ function SectionRow({
<ExpandMoreIcon fontSize="small" />
</IconButton>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", px: 1.5, pb: 1 }}>
{t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")} · {t("cvEditorSectionEntryCount", { count: customSection?.items.length ?? (outlineSection?.kind === "entries" ? outlineSection.entries.length : editableItems.length) })} · {t(customSection ? "cvEditorCvOnly" : "cvEditorSourceProfile")}
</Typography>
<Collapse in={expanded} unmountOnExit>
<Box ref={contentRef} sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
@@ -1378,9 +1412,10 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings, onGene
function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[]; onRestore: (v: number) => void }) {
const { language, t } = useI18n();
if (versions.length === 0) return <Typography variant="body2" color="text.secondary">{t("cvEditorNoVersions")}</Typography>;
return (
<Stack spacing={0.5}>
<Alert severity="info" role="note" sx={{ mb: 1 }}>{t("cvEditorHistoryScope")}</Alert>
{versions.length === 0 && <Typography variant="body2" color="text.secondary">{t("cvEditorNoVersions")}</Typography>}
<Typography variant="caption" color="text.secondary">{t("cvEditorVersionCount", { count: versions.length })}</Typography>
{versions.map((v) => {
const sources = { autosave: "historySourceAutosave", manual: "historySourceManual", restore: "historySourceRestore", create: "historySourceCreate" } as const;