188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { saveWithConflictRetry } from "@/lib/editor/autosave";
|
|
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<Value, Draft = Value>({
|
|
value,
|
|
valid,
|
|
initialVersion,
|
|
persist,
|
|
delay = 900,
|
|
storageKey,
|
|
localDraft,
|
|
parseLocalDraft,
|
|
restoreLocalDraft,
|
|
}: {
|
|
value: Value;
|
|
valid: boolean;
|
|
initialVersion: number;
|
|
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 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) => {
|
|
setSaving(true);
|
|
setErrorFingerprint(null);
|
|
try {
|
|
const attempt = await saveWithConflictRetry(persist, snapshot, version);
|
|
const result = attempt.response;
|
|
if (attempt.retried) setVersion(attempt.version);
|
|
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 : "บันทึกอัตโนมัติไม่สำเร็จ");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
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 Value, fingerprint, draftFingerprint);
|
|
}, manual ? 0 : delay);
|
|
return () => window.clearTimeout(timer);
|
|
}, [delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]);
|
|
|
|
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";
|
|
|
|
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) return;
|
|
setErrorFingerprint(null);
|
|
setManualRequest((current) => current + 1);
|
|
},
|
|
};
|
|
}
|