"use client"; import { useRouter } from "next/navigation"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import { toast } from "sonner"; import { parseLocalDraft as parseStoredDraft, serializeLocalDraft } from "@/lib/editor/local-draft"; import { parseInvalidationEvent, type AdminEditorScope } from "@/lib/events/invalidation"; export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error" | "conflict"; interface SaveResult { status: string; message?: string; version?: number; currentVersion?: number; } interface SaveIntent { overwrite?: boolean; } export function useAutosave({ value, valid, initialVersion, persist, delay = 2_500, storageKey, localDraft, parseLocalDraft, restoreLocalDraft, guideId, scope, }: { value: Value; valid: boolean; initialVersion: number; persist: (value: Value, expectedVersion: number, intent?: SaveIntent) => Promise; delay?: number; storageKey?: string; localDraft?: Draft; parseLocalDraft?: (value: unknown) => Draft | null; restoreLocalDraft?: (draft: Draft) => void; guideId: string; scope: AdminEditorScope; }) { const router = useRouter(); 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(null); const [saving, setSaving] = useState(false); const [version, setVersion] = useState(initialVersion); const [hydrated, setHydrated] = useState(!storageKey); const [manualRequest, setManualRequest] = useState(0); const [conflictVersion, setConflictVersion] = useState(null); const [conflictActor, setConflictActor] = useState(null); const [overwriteRequest, setOverwriteRequest] = useState(null); const handledManualRequest = useRef(0); const storageWarningShown = useRef(false); const restore = useEffectEvent((serialized: string) => { if (!parseLocalDraft || !restoreLocalDraft) return false; const stored = parseStoredDraft(serialized, parseLocalDraft); if (!stored) return false; restoreLocalDraft(stored.draft); return true; }); const validStoredDraft = useEffectEvent((serialized: string) => Boolean(parseStoredDraft(serialized, parseLocalDraft ?? (() => null))), ); useEffect(() => { if (!storageKey) return; const timer = window.setTimeout(() => { try { const serialized = window.localStorage.getItem(storageKey); if (!serialized) { setHydrated(true); return; } if (!validStoredDraft(serialized)) { window.localStorage.removeItem(storageKey); setHydrated(true); return; } let decided = false; const finish = (shouldRestore: boolean) => { if (decided) return; decided = true; if (shouldRestore) restore(serialized); else window.localStorage.removeItem(storageKey); setHydrated(true); }; toast("พบ Local draft ต้องการกู้คืนหรือไม่?", { id: `restore:${storageKey}`, description: "หากไม่เลือก ระบบจะใช้ข้อมูลล่าสุดจากเซิร์ฟเวอร์", duration: 10_000, action: { label: "กู้คืน", onClick: () => finish(true) }, cancel: { label: "ไม่ใช้", onClick: () => finish(false) }, onDismiss: () => finish(false), onAutoClose: () => finish(false), }); } catch { if (!storageWarningShown.current) { storageWarningShown.current = true; toast.error("ไม่สามารถเปิด Local draft ได้"); } setHydrated(true); } }, 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, expectedVersion = version, overwrite = false) => { setSaving(true); setErrorFingerprint(null); try { const result = await persist(snapshot, expectedVersion, overwrite ? { overwrite: true } : undefined); if (result.status === "conflict") { if (result.currentVersion) setVersion(result.currentVersion); setConflictVersion(result.currentVersion ?? expectedVersion); setConflictActor(null); return; } if (result.status === "error") { setErrorFingerprint(snapshotFingerprint); toast.error(result.message ?? "บันทึกอัตโนมัติไม่สำเร็จ"); return; } if (result.version) setVersion(result.version); setSavedFingerprint(snapshotFingerprint); setSavedDraftFingerprint(snapshotDraftFingerprint); setConflictVersion(null); setConflictActor(null); } catch (cause) { setErrorFingerprint(snapshotFingerprint); toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ"); } finally { setSaving(false); } }); const receiveInvalidation = useEffectEvent((message: Event) => { if (!(message instanceof MessageEvent) || typeof message.data !== "string") return; const event = parseInvalidationEvent(message.data); if (!event || event.type !== "admin.updated" || event.id !== guideId || event.version <= version) return; if (event.scope && event.scope !== scope) { setVersion((current) => Math.max(current, event.version)); setConflictVersion((current) => current === null ? null : Math.max(current, event.version)); return; } if (hydrated && valid && !saving && fingerprint === savedFingerprint) { if (event.operation === "overwrite" && event.actorName) { toast.warning(`${event.actorName} เขียนทับข้อมูลส่วนนี้ด้วย Local draft`, { description: "ระบบโหลดข้อมูลล่าสุดให้แล้ว", }); } setVersion((current) => Math.max(current, event.version)); router.refresh(); return; } setVersion((current) => Math.max(current, event.version)); setConflictVersion((current) => Math.max(current ?? 0, event.version)); setConflictActor(event.operation === "overwrite" ? event.actorName ?? null : null); }); useEffect(() => { const source = new EventSource("/api/events/admin"); source.addEventListener("invalidation", receiveInvalidation); return () => { source.removeEventListener("invalidation", receiveInvalidation); source.close(); }; }, []); useEffect(() => { const toastId = `autosave-conflict:${guideId}:${scope}`; if (conflictVersion === null) { toast.dismiss(toastId); return; } toast.error("Autosave ถูกหยุดชั่วคราว", { id: toastId, description: conflictActor ? `${conflictActor} เขียนทับข้อมูลส่วนนี้ แต่ Draft ของคุณยังถูกเก็บไว้` : "มีผู้แก้ไขคนอื่นบันทึกข้อมูลส่วนนี้ กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้", duration: Infinity, action: { label: "ดูตัวเลือก", onClick: () => document.getElementById("autosave-conflict")?.scrollIntoView({ behavior: "smooth", block: "center" }), }, }); return () => { toast.dismiss(toastId); }; }, [conflictActor, conflictVersion, guideId, scope]); useEffect(() => { if (!hydrated || !valid || saving || conflictVersion !== null) 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 Value, fingerprint, draftFingerprint); }, manual ? 0 : delay); return () => window.clearTimeout(timer); }, [conflictVersion, delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]); useEffect(() => { if (overwriteRequest === null || !valid || saving) return; const timer = window.setTimeout(() => { void save( JSON.parse(fingerprint) as Value, fingerprint, draftFingerprint, overwriteRequest, true, ).finally(() => setOverwriteRequest(null)); }, 0); return () => window.clearTimeout(timer); }, [draftFingerprint, fingerprint, overwriteRequest, saving, valid]); let state: AutosaveState; if (conflictVersion !== null) state = "conflict"; else if (!valid) state = "incomplete"; else if (saving) state = "saving"; else if (fingerprint === errorFingerprint) state = "error"; else if (fingerprint !== savedFingerprint) state = "unsaved"; else state = "saved"; useEffect(() => { const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean }; browserWindow.__guideHasUnsavedChanges = state !== "saved"; if (state === "saved") return () => { browserWindow.__guideHasUnsavedChanges = false; }; const warnBeforeLeaving = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", warnBeforeLeaving); return () => { window.removeEventListener("beforeunload", warnBeforeLeaving); browserWindow.__guideHasUnsavedChanges = false; }; }, [state]); return { state, saveNow: () => { if (!valid || saving || conflictVersion !== null) return; setErrorFingerprint(null); setManualRequest((current) => current + 1); }, conflictVersion, canOverwrite: valid && !saving && overwriteRequest === null, reloadLatest: () => { try { if (storageKey) window.localStorage.removeItem(storageKey); } finally { window.location.reload(); } }, overwrite: () => { if (!valid || saving || conflictVersion === null) return; setOverwriteRequest(conflictVersion); }, }; }