feat : dont conflit with other editor
This commit is contained in:
+76
-11
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
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";
|
||||
import { parseInvalidationEvent, type AdminEditorScope } from "@/lib/events/invalidation";
|
||||
|
||||
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error";
|
||||
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error" | "conflict";
|
||||
|
||||
interface SaveResult {
|
||||
status: string;
|
||||
@@ -25,6 +26,8 @@ export function useAutosave<Value, Draft = Value>({
|
||||
localDraft,
|
||||
parseLocalDraft,
|
||||
restoreLocalDraft,
|
||||
guideId,
|
||||
scope,
|
||||
}: {
|
||||
value: Value;
|
||||
valid: boolean;
|
||||
@@ -35,7 +38,10 @@ export function useAutosave<Value, Draft = Value>({
|
||||
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);
|
||||
@@ -45,6 +51,8 @@ export function useAutosave<Value, Draft = Value>({
|
||||
const [version, setVersion] = useState(initialVersion);
|
||||
const [hydrated, setHydrated] = useState(!storageKey);
|
||||
const [manualRequest, setManualRequest] = useState(0);
|
||||
const [conflictVersion, setConflictVersion] = useState<number | null>(null);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<number | null>(null);
|
||||
const handledManualRequest = useRef(0);
|
||||
const storageWarningShown = useRef(false);
|
||||
|
||||
@@ -117,15 +125,17 @@ export function useAutosave<Value, Draft = Value>({
|
||||
}
|
||||
}, [draftFingerprint, hydrated, localDraft, savedDraftFingerprint, storageKey, version]);
|
||||
|
||||
const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string) => {
|
||||
const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string, expectedVersion = version) => {
|
||||
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") {
|
||||
const result = await persist(snapshot, expectedVersion);
|
||||
if (result.status === "conflict") {
|
||||
if (result.currentVersion) setVersion(result.currentVersion);
|
||||
setConflictVersion(result.currentVersion ?? expectedVersion);
|
||||
return;
|
||||
}
|
||||
if (result.status === "error") {
|
||||
setErrorFingerprint(snapshotFingerprint);
|
||||
toast.error(result.message ?? "บันทึกอัตโนมัติไม่สำเร็จ");
|
||||
return;
|
||||
@@ -133,6 +143,7 @@ export function useAutosave<Value, Draft = Value>({
|
||||
if (result.version) setVersion(result.version);
|
||||
setSavedFingerprint(snapshotFingerprint);
|
||||
setSavedDraftFingerprint(snapshotDraftFingerprint);
|
||||
setConflictVersion(null);
|
||||
} catch (cause) {
|
||||
setErrorFingerprint(snapshotFingerprint);
|
||||
toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ");
|
||||
@@ -141,8 +152,35 @@ export function useAutosave<Value, Draft = Value>({
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
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));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !valid || saving) return;
|
||||
const source = new EventSource("/api/events/admin");
|
||||
source.addEventListener("invalidation", receiveInvalidation);
|
||||
return () => {
|
||||
source.removeEventListener("invalidation", receiveInvalidation);
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@@ -150,10 +188,24 @@ export function useAutosave<Value, Draft = Value>({
|
||||
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]);
|
||||
}, [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,
|
||||
).finally(() => setOverwriteRequest(null));
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [draftFingerprint, fingerprint, overwriteRequest, saving, valid]);
|
||||
|
||||
let state: AutosaveState;
|
||||
if (!valid) state = "incomplete";
|
||||
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";
|
||||
@@ -179,9 +231,22 @@ export function useAutosave<Value, Draft = Value>({
|
||||
return {
|
||||
state,
|
||||
saveNow: () => {
|
||||
if (!valid || saving) return;
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user