fix(cv): protect revisions and measure rendering readiness

This commit is contained in:
cesnimda
2026-10-07 07:56:35 +02:00
parent ac6d78289d
commit 046dc304aa
175 changed files with 16252 additions and 41 deletions
+70 -14
View File
@@ -41,7 +41,7 @@ import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
const MIN_PREVIEW_ZOOM = 0.2;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
function previewDocumentHtml(html: string, pageHeightPx: number, pageMarginMm: number, pageIndex = 0): string {
const height = Math.max(1, Math.round(pageHeightPx * 100) / 100);
@@ -89,6 +89,9 @@ export default function CvBuilderEditor() {
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveRevision = useRef(0);
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
const serverVersion = useRef(0);
const editorEpoch = useRef(0);
const revisionConflict = useRef(false);
const latestSettings = useRef<CvVariantSettings | null>(null);
const latestName = useRef("");
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -101,8 +104,9 @@ export default function CvBuilderEditor() {
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
const pageMarginMm = useMemo(() => {
const themeMargin = themes.find((theme) => theme.id === settings?.themeId)?.pageMarginMm;
return settings?.pageMarginMm ?? themeMargin ?? 16;
}, [settings?.pageMarginMm, settings?.themeId, themes]);
const density = settings?.density === "compact" ? 0.82 : settings?.density === "roomy" ? 1.18 : 1;
return (settings?.pageMarginMm ?? themeMargin ?? 16) * density;
}, [settings?.pageMarginMm, settings?.themeId, settings?.density, themes]);
useEffect(() => {
let alive = true;
@@ -130,6 +134,9 @@ export default function CvBuilderEditor() {
const applyVariant = (variant: CvVariant) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveRevision.current += 1;
editorEpoch.current += 1;
serverVersion.current = variant.version;
revisionConflict.current = false;
setName(variant.name);
setSettings(variant.settings);
latestName.current = variant.name;
@@ -167,14 +174,23 @@ export default function CvBuilderEditor() {
}, [settings, previewRevision]);
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
const epoch = editorEpoch.current;
const save = async () => {
if (epoch !== editorEpoch.current || revisionConflict.current) return false;
if (saveRevision.current === revision) setSaveState("saving");
try {
await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" });
const saved = await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave", expectedVersion: serverVersion.current });
if (epoch !== editorEpoch.current) return false;
serverVersion.current = saved.version;
if (saveRevision.current === revision) setSaveState("saved");
return true;
} catch {
if (saveRevision.current === revision) setSaveState("error");
} catch (error) {
if (epoch !== editorEpoch.current) return false;
const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code;
if (code === "cv_revision_conflict" || code === "cv_revision_required") {
revisionConflict.current = true;
setSaveState("conflict");
} else if (saveRevision.current === revision) setSaveState("error");
return false;
}
};
@@ -188,9 +204,9 @@ export default function CvBuilderEditor() {
latestSettings.current = next;
latestName.current = nextName;
const revision = ++saveRevision.current;
setSaveState("unsaved");
setSaveState(revisionConflict.current ? "conflict" : "unsaved");
if (saveTimer.current) clearTimeout(saveTimer.current);
if (!nextName.trim()) return;
if (!nextName.trim() || revisionConflict.current) return;
saveTimer.current = setTimeout(async () => {
await performSave(next, nextName, revision);
}, 800);
@@ -245,7 +261,27 @@ export default function CvBuilderEditor() {
if (settings) scheduleSave(settings, value);
};
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error";
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error" || saveState === "conflict";
const downloadUnsavedCopy = () => {
const url = URL.createObjectURL(new Blob([JSON.stringify({ name: latestName.current, settings: latestSettings.current, expectedVersion: serverVersion.current }, null, 2)], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = "unsaved-cv-settings.json";
link.click();
URL.revokeObjectURL(url);
};
const reloadSavedVariant = async () => {
if (!(await confirmAction(t("cvEditorConflictReloadMessage"), { title: t("cvEditorConflictReload"), confirmLabel: t("cvEditorConflictReload"), destructive: true }))) return;
const revision = saveRevision.current;
await saveQueue.current;
try {
const saved = await cvBuilderApi.get(variantId);
if (saveRevision.current === revision) applyVariant(saved);
}
catch (error) { toast(getApiErrorMessage(error, t("cvEditorOpenFailed")), "error"); }
};
const blocker = useBlocker(hasUnsavedChanges);
useEffect(() => {
@@ -356,11 +392,23 @@ export default function CvBuilderEditor() {
return;
}
try {
const updated = await cvBuilderApi.restore(variantId, version);
applyVariant(updated);
await saveQueue.current;
const revision = saveRevision.current;
const updated = await cvBuilderApi.restore(variantId, version, serverVersion.current);
if (saveRevision.current === revision) applyVariant(updated);
else {
// Edits made while restore was in flight remain recoverable; never replace them on response.
revisionConflict.current = true;
setSaveState("conflict");
}
await loadVersions();
toast(t("cvEditorRestoredVersion", { version }), "success");
} catch (err) {
const code = (err as { response?: { data?: { code?: string } } })?.response?.data?.code;
if (code === "cv_revision_conflict" || code === "cv_revision_required") {
revisionConflict.current = true;
setSaveState("conflict");
}
toast(getApiErrorMessage(err, t("cvEditorRestoreFailed")), "error");
}
};
@@ -423,6 +471,13 @@ export default function CvBuilderEditor() {
return (
<Stack spacing={1.5} sx={{ minWidth: 0 }}>
{saveState === "conflict" && <Alert severity="warning">
{t("cvEditorConflictMessage")}
<Stack direction="row" gap={1} flexWrap="wrap" sx={{ mt: 1 }}>
<Button size="small" onClick={downloadUnsavedCopy}>{t("cvEditorConflictDownload")}</Button>
<Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button>
</Stack>
</Alert>}
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
@@ -449,9 +504,9 @@ export default function CvBuilderEditor() {
</Paper>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
<Paper role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
<Tab label={t("cvEditorTemplate")} />
<Tab label={t("cvEditorContent")} />
<Tab label={t("cvEditorCustomize")} />
@@ -524,6 +579,7 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
saving: { label: t("cvEditorSaving"), color: "warning" },
saved: { label: t("cvEditorSaved"), color: "success" },
error: { label: t("cvEditorSaveFailed"), color: "error" },
conflict: { label: t("cvEditorConflict"), color: "warning" },
};
const m = map[state];
if (!m.label) return null;
@@ -1113,7 +1169,7 @@ function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[];
</Typography>
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {v.source}</Typography>
</Box>
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
{!v.isCurrent && <Button size="small" aria-label={t("cvEditorRestoreTitle", { version: v.version })} onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
</Paper>
))}
</Stack>
+1 -1
View File
@@ -100,7 +100,7 @@ export default function CvBuilderPage() {
if (!nextName?.trim() || nextName.trim() === current?.name) return;
try {
const variant = await cvBuilderApi.get(id);
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" });
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
toast(t("cvDashboardRenamed"), "success");
} catch (err) { toast(getApiErrorMessage(err, t("cvDashboardRenameFailed")), "error"); }