feat : update
CI / Verify (push) Successful in 1m28s
CI / Build immutable images and deploy (push) Successful in 2m5s

This commit is contained in:
2026-09-05 18:15:06 +07:00 Unverified
parent e66b5a3a0d
commit 5aa0ae80c1
42 changed files with 4613 additions and 67 deletions
+30 -5
View File
@@ -55,23 +55,48 @@ export function useAutosave<Value, Draft = Value>({
restoreLocalDraft(stored.draft);
return true;
});
const validStoredDraft = useEffectEvent((serialized: string) =>
Boolean(parseStoredDraft(serialized, parseLocalDraft ?? (() => null))),
);
useEffect(() => {
if (!storageKey) return;
const timer = window.setTimeout(() => {
let restored = false;
try {
const serialized = window.localStorage.getItem(storageKey);
if (serialized) restored = restore(serialized);
if (serialized && !restored) window.localStorage.removeItem(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);
}
setHydrated(true);
if (restored) toast.info("กู้คืน Local draft แล้ว ระบบจะบันทึกอัตโนมัติเมื่อข้อมูลครบ");
}, 0);
return () => window.clearTimeout(timer);
}, [storageKey]);
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useEffectEvent, useState } from "react";
import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants";
type VisitorCounts = { global: number; guide: number | null };
export type EditorsByGuide = Record<string, Array<{ userId: string; name: string }>>;
function tabId(storageKey: string): string {
const existing = window.sessionStorage.getItem(storageKey);
if (existing) return existing;
const created = crypto.randomUUID();
window.sessionStorage.setItem(storageKey, created);
return created;
}
function useHeartbeat<T>({
endpoint,
guideId,
}: {
endpoint: string;
guideId: string | null;
}) {
const [value, setValue] = useState<T | null>(null);
const send = useEffectEvent(async (active = true) => {
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tabId: tabId(`buzz-sheet:${endpoint}:tab`), guideId, active }),
cache: "no-store",
keepalive: !active,
});
if (!response.ok) throw new Error("presence unavailable");
if (active) setValue(await response.json() as T);
} catch {
if (active) setValue(null);
}
});
useEffect(() => {
const immediate = window.setTimeout(() => void send(), 0);
const interval = window.setInterval(() => void send(), PRESENCE_HEARTBEAT_MS);
const refresh = () => {
if (document.visibilityState === "visible") void send();
};
const leave = () => void send(false);
window.addEventListener("focus", refresh);
window.addEventListener("pagehide", leave);
document.addEventListener("visibilitychange", refresh);
return () => {
window.clearInterval(interval);
window.clearTimeout(immediate);
window.removeEventListener("focus", refresh);
window.removeEventListener("pagehide", leave);
document.removeEventListener("visibilitychange", refresh);
};
}, [endpoint, guideId]);
return value;
}
export function useVisitorPresence(guideId: string | null) {
return useHeartbeat<VisitorCounts>({ endpoint: "/api/presence/visitors", guideId });
}
export function useAdminPresence(guideId: string | null) {
return useHeartbeat<{ editorsByGuide: EditorsByGuide }>({ endpoint: "/api/presence/admin", guideId });
}