feat(admin) : persist drafts and add constellation conditions
CI / Verify (push) Successful in 59s
CI / Build immutable images and deploy (push) Successful in 1m58s

This commit is contained in:
2026-09-02 12:40:13 +07:00 Unverified
parent 5a2e2e4829
commit 7708a2df31
17 changed files with 3738 additions and 100 deletions
+91 -15
View File
@@ -1,47 +1,110 @@
"use client";
import { useEffect, useEffectEvent, useState } from "react";
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { toast } from "sonner";
import { parseLocalDraft as parseStoredDraft, serializeLocalDraft } from "@/lib/editor/local-draft";
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error";
interface SaveResult {
status: string;
message?: string;
version?: number;
currentVersion?: number;
}
export function useAutosave<T>({
export function useAutosave<Value, Draft = Value>({
value,
valid,
initialVersion,
persist,
delay = 900,
storageKey,
localDraft,
parseLocalDraft,
restoreLocalDraft,
}: {
value: T;
value: Value;
valid: boolean;
initialVersion: number;
persist: (value: T, expectedVersion: number) => Promise<SaveResult>;
persist: (value: Value, expectedVersion: number) => Promise<SaveResult>;
delay?: number;
storageKey?: string;
localDraft?: Draft;
parseLocalDraft?: (value: unknown) => Draft | null;
restoreLocalDraft?: (draft: Draft) => void;
}) {
const fingerprint = JSON.stringify(value);
const draftFingerprint = JSON.stringify(localDraft ?? value);
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
const [savedDraftFingerprint, setSavedDraftFingerprint] = useState(draftFingerprint);
const [errorFingerprint, setErrorFingerprint] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [version, setVersion] = useState(initialVersion);
const [hydrated, setHydrated] = useState(!storageKey);
const [manualRequest, setManualRequest] = useState(0);
const handledManualRequest = useRef(0);
const storageWarningShown = useRef(false);
const save = useEffectEvent(async (snapshot: T, snapshotFingerprint: string) => {
const restore = useEffectEvent((serialized: string) => {
if (!parseLocalDraft || !restoreLocalDraft) return false;
const stored = parseStoredDraft(serialized, parseLocalDraft);
if (!stored) return false;
restoreLocalDraft(stored.draft);
return true;
});
useEffect(() => {
if (!storageKey) return;
const timer = window.setTimeout(() => {
let restored = false;
try {
const serialized = window.localStorage.getItem(storageKey);
if (serialized) restored = restore(serialized);
if (serialized && !restored) window.localStorage.removeItem(storageKey);
} catch {
if (!storageWarningShown.current) {
storageWarningShown.current = true;
toast.error("ไม่สามารถเปิด Local draft ได้");
}
}
setHydrated(true);
if (restored) toast.info("กู้คืน Local draft แล้ว ระบบจะบันทึกอัตโนมัติเมื่อข้อมูลครบ");
}, 0);
return () => window.clearTimeout(timer);
}, [storageKey]);
useEffect(() => {
if (!hydrated || !storageKey || localDraft === undefined) return;
try {
if (draftFingerprint === savedDraftFingerprint) {
window.localStorage.removeItem(storageKey);
return;
}
window.localStorage.setItem(storageKey, serializeLocalDraft(localDraft, version));
} catch {
if (!storageWarningShown.current) {
storageWarningShown.current = true;
toast.error("บันทึก Local draft ไม่สำเร็จ กรุณาตรวจสอบพื้นที่ของเบราว์เซอร์");
}
}
}, [draftFingerprint, hydrated, localDraft, savedDraftFingerprint, storageKey, version]);
const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string) => {
setSaving(true);
setErrorFingerprint(null);
try {
const result = await persist(snapshot, version);
if (result.status === "error") {
if (result.status === "error" || result.status === "conflict") {
if (result.currentVersion) setVersion(result.currentVersion);
setErrorFingerprint(snapshotFingerprint);
toast.error(result.message ?? "บันทึกอัตโนมัติไม่สำเร็จ");
return;
}
if (result.version) setVersion(result.version);
setSavedFingerprint(snapshotFingerprint);
setSavedDraftFingerprint(snapshotDraftFingerprint);
} catch (cause) {
setErrorFingerprint(snapshotFingerprint);
toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ");
@@ -51,16 +114,29 @@ export function useAutosave<T>({
});
useEffect(() => {
if (!valid || saving || fingerprint === savedFingerprint || fingerprint === errorFingerprint) return;
if (!hydrated || !valid || saving) return;
const manual = manualRequest !== handledManualRequest.current;
if (!manual && (fingerprint === savedFingerprint || fingerprint === errorFingerprint)) return;
if (manual) handledManualRequest.current = manualRequest;
const timer = window.setTimeout(() => {
void save(JSON.parse(fingerprint) as T, fingerprint);
}, delay);
void save(JSON.parse(fingerprint) as Value, fingerprint, draftFingerprint);
}, manual ? 0 : delay);
return () => window.clearTimeout(timer);
}, [delay, errorFingerprint, fingerprint, savedFingerprint, saving, valid]);
}, [delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]);
if (!valid) return "incomplete";
if (saving) return "saving";
if (fingerprint === errorFingerprint) return "error";
if (fingerprint !== savedFingerprint) return "unsaved";
return "saved";
let state: AutosaveState;
if (!valid) state = "incomplete";
else if (saving) state = "saving";
else if (fingerprint === errorFingerprint) state = "error";
else if (fingerprint !== savedFingerprint) state = "unsaved";
else state = "saved";
return {
state,
saveNow: () => {
if (!valid || saving) return;
setErrorFingerprint(null);
setManualRequest((current) => current + 1);
},
};
}