feat(cv): redesign document editing with revision-safe letters and writing tools
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user