Files
buzz-sheet/hooks/use-autosave.ts
gunshiz c3a2aa88d6
CI / Verify (push) Successful in 1m20s
CI / Build immutable images and deploy (push) Successful in 1m51s
feat(editor) : notify peers of draft overwrite
2026-09-15 19:46:40 +07:00

289 lines
11 KiB
TypeScript

"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, Draft = Value>({
value,
valid,
initialVersion,
persist,
delay = 900,
storageKey,
localDraft,
parseLocalDraft,
restoreLocalDraft,
guideId,
scope,
}: {
value: Value;
valid: boolean;
initialVersion: number;
persist: (value: Value, expectedVersion: number, intent?: SaveIntent) => Promise<SaveResult>;
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<string | null>(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<number | null>(null);
const [conflictActor, setConflictActor] = useState<string | null>(null);
const [overwriteRequest, setOverwriteRequest] = useState<number | null>(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);
},
};
}