feat(cms): recover drafts and failed imports with verified UI workflows
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import {
decodeRecovery,
encodeRecovery,
type RecoveryKind,
recoveryKey,
} from "@/lib/form-recovery";
/** Local fallback complements server autosave and survives an expired session. */
export function useFormRecovery({
userId,
kind,
record,
dirty,
snapshot,
restore,
serverDraft,
}: {
userId?: string;
kind: RecoveryKind;
record: string;
dirty: boolean;
serverDraft?: unknown;
snapshot: () => unknown;
restore: (payload: Record<string, unknown>) => void;
}) {
const t = useTranslations("draftRecovery");
const key = userId ? recoveryKey(userId, kind, record) : null;
const current = useRef({ dirty, snapshot, restore, key });
current.current = { dirty, snapshot, restore, key };
const [found, setFound] = useState<ReturnType<typeof decodeRecovery> | null>(
null,
);
const [state, setState] = useState<"ready" | "saved" | "unavailable">(
"ready",
);
const blocked = useRef(true);
const last = useRef<string | null>(null);
const suppressed = useRef(false);
const lastPayload = useRef("");
const savedSnapshot = useRef("");
const flush = useRef(() => {});
useEffect(() => {
blocked.current = true;
suppressed.current = false;
lastPayload.current = "";
setFound(null);
setState("ready");
if (!key) return;
try {
const raw = localStorage.getItem(key);
last.current = raw;
if (raw) {
try {
setFound(decodeRecovery(kind, raw));
} catch {
localStorage.removeItem(key);
last.current = null;
blocked.current = false;
}
} else blocked.current = false;
} catch {
setState("unavailable");
blocked.current = false;
}
function persist() {
if (
current.current.key !== key ||
blocked.current ||
!current.current.dirty
)
return;
if (
suppressed.current &&
JSON.stringify(current.current.snapshot()) === savedSnapshot.current
)
return;
suppressed.current = false;
try {
// Do not overwrite a draft written by another tab without offering recovery.
const existing = localStorage.getItem(key as string);
if (existing !== last.current && existing) {
setFound(decodeRecovery(kind, existing));
last.current = existing;
blocked.current = true;
return;
}
const payload = encodeRecovery(kind, current.current.snapshot(), 0);
if (payload === lastPayload.current && existing === last.current)
return;
const raw = encodeRecovery(kind, current.current.snapshot());
localStorage.setItem(key as string, raw);
lastPayload.current = payload;
last.current = raw;
setState("saved");
} catch {
setState("unavailable");
}
}
flush.current = persist;
const timer = window.setInterval(persist, 750);
const visibility = () => {
if (document.visibilityState === "hidden") persist();
};
window.addEventListener("pagehide", persist);
window.addEventListener("beforeunload", persist);
document.addEventListener("visibilitychange", visibility);
return () => {
persist();
clearInterval(timer);
window.removeEventListener("pagehide", persist);
window.removeEventListener("beforeunload", persist);
document.removeEventListener("visibilitychange", visibility);
flush.current = () => {};
};
}, [key, kind]);
function clear() {
blocked.current = false;
lastPayload.current = "";
suppressed.current = true;
savedSnapshot.current = JSON.stringify(current.current.snapshot());
setFound(null);
if (!key) return;
try {
if (localStorage.getItem(key) === last.current)
localStorage.removeItem(key);
last.current = null;
setState("ready");
} catch {
setState("unavailable");
}
}
useEffect(() => {
if (!found || !serverDraft) return;
try {
if (
encodeRecovery(kind, found.payload, 0) ===
encodeRecovery(kind, serverDraft, 0)
) {
setFound(null);
blocked.current = false;
}
} catch {
/* The server keeps its own validation and recovery flow. */
}
}, [found, serverDraft, kind]);
// A new edit after successful save enables the next recovery snapshot.
function changed() {
suppressed.current = false;
}
return {
clear,
changed,
flush: () => flush.current(),
panel: !key ? null : (
<div className="grid gap-2 text-xs" aria-live="polite">
<p>
{t(found ? "found" : state)}
{found && (
<>
{" "}
<time dateTime={new Date(found.savedAt).toISOString()}>
{new Date(found.savedAt).toLocaleString()}
</time>
</>
)}
</p>
{found && (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!window.confirm(t("confirmRestore"))) return;
current.current.restore(found.payload);
blocked.current = false;
suppressed.current = false;
setFound(null);
}}
>
{t("restore")}
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!window.confirm(t("confirmDiscard"))) return;
clear();
blocked.current = false;
suppressed.current = false;
}}
>
{t("discard")}
</button>
</div>
)}
</div>
),
};
}