feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -0,0 +1,176 @@
|
||||
"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 {
|
||||
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>
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user