"use client" import { useEffect, useRef, useState } from "react" import { useSession } from "next-auth/react" import { SyncScope, SYNC_KEYS, SYNC_TS_KEY, readScopeFromLocal, writeScopeToLocal } from "@/lib/sync-keys" const SCOPES: SyncScope[] = ["sessions", "settings", "collections"] const PUSH_DEBOUNCE_MS = 3000 const TRACKED_KEYS = new Set(SCOPES.flatMap((s) => SYNC_KEYS[s])) function scopeOfKey(key: string): SyncScope | null { for (const s of SCOPES) { if (SYNC_KEYS[s].includes(key)) return s } return null } export type SyncStatus = "idle" | "pulling" | "pushing" | "error" | "offline" interface UseSyncResult { status: SyncStatus lastSyncAt: number | null error: string | null pullAll: () => Promise pushAll: () => Promise } export function useCloudSync(): UseSyncResult { const { data: session, status: authStatus } = useSession() const ghId = (session as any)?.ghId as string | undefined const authed = authStatus === "authenticated" && !!ghId const [status, setStatus] = useState("idle") const [lastSyncAt, setLastSyncAt] = useState(null) const [error, setError] = useState(null) const dirtyScopes = useRef>(new Set()) const pushTimer = useRef(null) const initialPullDone = useRef(false) async function pullScope(scope: SyncScope) { const res = await fetch(`/api/sync?scope=${scope}`, { credentials: "include" }) if (!res.ok) throw new Error(`pull ${scope}: ${res.status}`) const remote = await res.json() const local = readScopeFromLocal(scope) // LWW: nếu server mới hơn → adopt remote. Bằng nhau hoặc local mới hơn → giữ local (sẽ push sau). if ((remote.updated_at ?? 0) > local.updated_at) { writeScopeToLocal(scope, remote) // Notify React app to re-read localStorage. window.dispatchEvent(new CustomEvent("tans:cloud-pull", { detail: { scope } })) } else if (local.updated_at > (remote.updated_at ?? 0) && Object.keys(local.data).length > 0) { dirtyScopes.current.add(scope) } } async function pushScope(scope: SyncScope) { const local = readScopeFromLocal(scope) if (!local.updated_at || Object.keys(local.data).length === 0) return const res = await fetch(`/api/sync?scope=${scope}`, { method: "PUT", credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify(local), }) if (!res.ok) throw new Error(`push ${scope}: ${res.status}`) } async function pullAll() { if (!authed) return setStatus("pulling") setError(null) try { for (const s of SCOPES) await pullScope(s) setLastSyncAt(Date.now()) setStatus("idle") } catch (e: any) { setError(e?.message ?? "pull_error") setStatus("error") } } async function pushAll() { if (!authed) return const scopes = Array.from(dirtyScopes.current) if (scopes.length === 0) return dirtyScopes.current.clear() setStatus("pushing") setError(null) try { for (const s of scopes) await pushScope(s) setLastSyncAt(Date.now()) setStatus("idle") } catch (e: any) { // Re-mark dirty so next attempt retries. for (const s of scopes) dirtyScopes.current.add(s) setError(e?.message ?? "push_error") setStatus("error") } } function schedulePush() { if (pushTimer.current) clearTimeout(pushTimer.current) pushTimer.current = setTimeout(() => { pushTimer.current = null void pushAll() }, PUSH_DEBOUNCE_MS) } // Wrap localStorage.setItem to detect mutations on tracked keys (other tabs use 'storage' event). useEffect(() => { if (!authed) return if (typeof window === "undefined") return const orig = window.localStorage.setItem.bind(window.localStorage) function patched(key: string, value: string) { orig(key, value) if (TRACKED_KEYS.has(key)) { const s = scopeOfKey(key) if (s) { dirtyScopes.current.add(s) // Bump local timestamp so push includes correct ts. orig(SYNC_TS_KEY(s), String(Date.now())) schedulePush() } } } ;(window.localStorage as any).setItem = patched return () => { ;(window.localStorage as any).setItem = orig } // eslint-disable-next-line react-hooks/exhaustive-deps }, [authed]) // Initial pull on auth. useEffect(() => { if (!authed) return if (initialPullDone.current) return initialPullDone.current = true void (async () => { await pullAll() // After pull, if any scope was kept-local-newer, push it. if (dirtyScopes.current.size > 0) await pushAll() })() // eslint-disable-next-line react-hooks/exhaustive-deps }, [authed]) // Pull on focus / visibility change. useEffect(() => { if (!authed) return function onFocus() { void pullAll() } function onVisibility() { if (document.visibilityState === "visible") void pullAll() } window.addEventListener("focus", onFocus) document.addEventListener("visibilitychange", onVisibility) return () => { window.removeEventListener("focus", onFocus) document.removeEventListener("visibilitychange", onVisibility) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [authed]) return { status, lastSyncAt, error, pullAll, pushAll } }