feat : use manual save
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
"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 SaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error" | "conflict";
|
||||
|
||||
interface SaveResult {
|
||||
status: string;
|
||||
message?: string;
|
||||
version?: number;
|
||||
currentVersion?: number;
|
||||
}
|
||||
|
||||
interface SaveIntent {
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
export function useManualSave<Value, Draft = Value>({
|
||||
value,
|
||||
valid,
|
||||
initialVersion,
|
||||
persist,
|
||||
storageKey,
|
||||
localDraft,
|
||||
parseLocalDraft,
|
||||
restoreLocalDraft,
|
||||
guideId,
|
||||
scope,
|
||||
}: {
|
||||
value: Value;
|
||||
valid: boolean;
|
||||
initialVersion: number;
|
||||
persist: (value: Value, expectedVersion: number, intent?: SaveIntent) => Promise<SaveResult>;
|
||||
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 [conflictVersion, setConflictVersion] = useState<number | null>(null);
|
||||
const [conflictActor, setConflictActor] = useState<string | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
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 = async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string, expectedVersion = version, overwrite = false) => {
|
||||
if (savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
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 {
|
||||
savingRef.current = false;
|
||||
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 = `save-conflict:${guideId}:${scope}`;
|
||||
if (conflictVersion === null) {
|
||||
toast.dismiss(toastId);
|
||||
return;
|
||||
}
|
||||
toast.error("ไม่สามารถบันทึกได้เนื่องจากข้อมูลมีการเปลี่ยนแปลง", {
|
||||
id: toastId,
|
||||
description: conflictActor
|
||||
? `${conflictActor} เขียนทับข้อมูลส่วนนี้ แต่ Draft ของคุณยังถูกเก็บไว้`
|
||||
: "มีผู้แก้ไขคนอื่นบันทึกข้อมูลส่วนนี้ กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "ดูตัวเลือก",
|
||||
onClick: () => document.getElementById("save-conflict")?.scrollIntoView({ behavior: "smooth", block: "center" }),
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
}, [conflictActor, conflictVersion, guideId, scope]);
|
||||
|
||||
let state: SaveState;
|
||||
if (conflictVersion !== null) state = "conflict";
|
||||
else if (saving) state = "saving";
|
||||
else if (!valid) state = "incomplete";
|
||||
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,
|
||||
canSave: hydrated && valid && !saving && conflictVersion === null && fingerprint !== savedFingerprint,
|
||||
saveNow: () => {
|
||||
if (!hydrated || !valid || savingRef.current || conflictVersion !== null || fingerprint === savedFingerprint) return;
|
||||
void save(JSON.parse(fingerprint) as Value, fingerprint, draftFingerprint);
|
||||
},
|
||||
conflictVersion,
|
||||
canOverwrite: hydrated && valid && !saving,
|
||||
reloadLatest: () => {
|
||||
try {
|
||||
if (storageKey) window.localStorage.removeItem(storageKey);
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
overwrite: () => {
|
||||
if (!hydrated || !valid || savingRef.current || conflictVersion === null) return;
|
||||
void save(JSON.parse(fingerprint) as Value, fingerprint, draftFingerprint, conflictVersion, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user