Files
EpicNext-Cms/src/components/admin/article-recovery.tsx
T
Simo 88790d1a0d
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s
feat(cms): recover drafts and failed imports with verified UI workflows
2026-09-11 08:58:10 +02:00

178 lines
5.1 KiB
TypeScript

"use client";
import { useTranslations } from "next-intl";
import { type RefObject, useEffect, useRef, useState } from "react";
import {
autosaveArticle,
clearArticleRecovery,
loadArticleRecovery,
} from "@/actions/article-recovery";
import type { ArticleDraft } from "@/lib/article-draft";
export function useArticleRecovery(
key: string,
form: RefObject<HTMLFormElement | null>,
saving: RefObject<boolean>,
dirty: boolean,
restore: (draft: ArticleDraft, revision: boolean) => void,
) {
const t = useTranslations("pages.admin.articles.form");
const [recovery, setRecovery] = useState<Awaited<
ReturnType<typeof loadArticleRecovery>
> | null>(null);
const [message, setMessage] = useState("autosaveLoading");
const [reload, setReload] = useState(0);
const version = useRef(0);
const request = useRef<Promise<void> | null>(null);
const last = useRef("");
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
const blocked = useRef(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
useEffect(() => {
let active = true;
blocked.current = true;
last.current = "";
setMessage("autosaveLoading");
loadArticleRecovery(key)
.then((data) => {
if (!active) return;
version.current = data.version;
setRecovery(data);
blocked.current = Boolean(data.draft);
setMessage(data.draft ? "recoveryFound" : "autosaveReady");
})
.catch(() => {
if (active) setMessage("autosaveFailed");
});
const timer = setInterval(() => {
if (
blocked.current ||
!dirtyRef.current ||
saving.current ||
request.current ||
!form.current
)
return;
const data = new FormData(form.current);
const snapshot = JSON.stringify(Array.from(data.entries()));
if (snapshot === last.current) return;
setMessage("autosaveSaving");
request.current = autosaveArticle(key, version.current, data)
.then((result) => {
if (!active) return;
if (!result.ok) {
blocked.current = true;
setMessage("autosaveConflict");
return;
}
version.current = result.version;
last.current = snapshot;
setMessage("autosaveSaved");
})
.catch(() => {
if (active) setMessage("autosaveFailed");
})
.finally(() => {
request.current = null;
});
}, 2500);
return () => {
active = false;
clearInterval(timer);
};
}, [key, form, saving, reload]);
return {
draft: recovery?.draft?.payload,
wait: () => request.current ?? Promise.resolve(),
clear: () => clearArticleRecovery(key, version.current),
panel: (
<div className="grid gap-2 text-xs" aria-live="polite">
<p>{t(message)}</p>
{(message === "autosaveFailed" || message === "autosaveConflict") && (
<button
type="button"
className="btn btn-outline"
onClick={async () => {
if (saving.current) return;
// Reconcile server versions without replacing any current form contents.
blocked.current = true;
await request.current;
setReload((current) => current + 1);
}}
>
{t("retryRecovery")}
</button>
)}
{recovery?.draft && (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!recovery.draft || !window.confirm(t("confirmRecovery")))
return;
restore(recovery.draft.payload, false);
setRecovery({ ...recovery, draft: null });
blocked.current = false;
setMessage("autosaveReady");
}}
>
{t("recoverDraft")}
</button>
<button
type="button"
className="btn btn-outline"
onClick={async () => {
if (!window.confirm(t("confirmDiscardDraft"))) return;
try {
await clearArticleRecovery(key, version.current);
// Reload the version in case another tab changed it before discarding.
const data = await loadArticleRecovery(key);
version.current = data.version;
setRecovery(data);
blocked.current = Boolean(data.draft);
setMessage(data.draft ? "autosaveConflict" : "autosaveReady");
} catch {
setMessage("autosaveFailed");
}
}}
>
{t("discardDraft")}
</button>
</div>
)}
{!!recovery?.revisions.length && (
<details>
<summary>{t("revisionHistory")}</summary>
<ul className="grid gap-2 mt-2">
{recovery.revisions.map((revision) => (
<li key={revision.id} className="flex gap-2 items-center">
<time dateTime={revision.createdAt}>
{new Date(revision.createdAt).toLocaleString()}
</time>
<span>{revision.payload.title}</span>
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (
blocked.current ||
!window.confirm(t("confirmRevision"))
)
return;
restore(revision.payload, true);
}}
disabled={Boolean(recovery.draft)}
>
{t("restoreRevision")}
</button>
</li>
))}
</ul>
</details>
)}
</div>
),
};
}