import { useEffect, useRef, useState } from "react"; import type { Editor as TiptapEditor } from "@tiptap/core"; import type { HocuspocusProvider } from "@hocuspocus/provider"; import { Cloud, CloudOff, AlertTriangle, Loader2 } from "lucide-react"; import { Tooltip } from "./Tooltip"; /** * Server-side persistence pipeline state, as returned by * `GET /api/storage/status`. Mirrors the shape of `StorageStatus` * in `backend/src/hf-storage.ts` - if you add a field there, add * it here too. */ interface StorageStatus { enabled: boolean; datasetId: string; datasetReady: boolean; lastLocalSaveAt: number | null; lastCloudPushAt: number | null; pendingPush: boolean; lastError: { stage: "dataset-create" | "local-save" | "cloud-push"; message: string; statusCode?: number; at: number; docName?: string; } | null; } /** * What the user sees in the top bar, derived from BOTH the WS * connection state AND the server-side persistence pipeline. * * Severity ordering (worst first): error > offline > pending > saved. * The displayed status is always the worst applicable signal, so * a green "Saved" never wins over a red "Sync failed". */ type DisplayStatus = | "saved" // WS connected + dataset ready + no error + recent push | "pending" // edit in flight or push timer armed | "offline" // WS disconnected (network or container restart) | "error"; // backend reports lastError in the pipeline interface Props { editorInstance: TiptapEditor | null; providerRef: { current: HocuspocusProvider | null }; } const POLL_MS = 5_000; export function SyncIndicator({ editorInstance: _editorInstance, providerRef }: Props) { const [wsConnected, setWsConnected] = useState(false); const [hasLocalEdit, setHasLocalEdit] = useState(false); const [serverStatus, setServerStatus] = useState(null); const editTimerRef = useRef | null>(null); // ---- WS layer: connection + edit activity ------------------------------ // Same lazy-provider polling pattern as the previous version: the // provider is created by AFTER this component mounts, so a // useEffect([providerRef]) alone would never re-fire when it lands. useEffect(() => { let pollId: ReturnType | null = null; const attach = (p: HocuspocusProvider) => { const onConnect = () => setWsConnected(true); const onDisconnect = () => setWsConnected(false); const onSynced = () => setWsConnected(true); p.on("connect", onConnect); p.on("disconnect", onDisconnect); p.on("synced", onSynced); // Seed + 1s reconcile loop (some HF proxies eat the first // `connect` event; without this we'd stay "Offline" forever). const seed = () => { const ws = (p as any).configuration?.websocketProvider; setWsConnected(ws?.status === "connected"); }; seed(); const reconcile = setInterval(seed, 1000); // Listen at the Yjs layer so we see EVERY change (TipTap's // own `update` event misses hero/settings/citation edits // because those bypass prosemirror). const ydoc = (p as any).document as | { on: Function; off: Function } | undefined; const onYUpdate = (_u: Uint8Array, origin: unknown) => { if (origin === p) return; // remote update, not ours setHasLocalEdit(true); if (editTimerRef.current) clearTimeout(editTimerRef.current); // Local edit "settles" 1.5s after the last keystroke; this // is also roughly when the backend's `debouncedSave` fires // (2s), so the indicator briefly flashes pending then // recovers to saved/error based on the next poll. editTimerRef.current = setTimeout(() => setHasLocalEdit(false), 1500); }; ydoc?.on?.("update", onYUpdate); return () => { p.off("connect", onConnect); p.off("disconnect", onDisconnect); p.off("synced", onSynced); ydoc?.off?.("update", onYUpdate); clearInterval(reconcile); }; }; let cleanup: (() => void) | null = null; if (providerRef.current) { cleanup = attach(providerRef.current); } else { pollId = setInterval(() => { const p = providerRef.current; if (!p) return; if (pollId) { clearInterval(pollId); pollId = null; } cleanup = attach(p); }, 100); } return () => { if (pollId) clearInterval(pollId); if (cleanup) cleanup(); }; }, [providerRef]); // ---- Server pipeline polling ------------------------------------------- // Cheap GET every 5s. The backend tracker updates in-process on // every save/push/error so the worst-case latency for surfacing // a problem is one poll interval. We don't use SSE/WS for this // because the data is tiny, the polling interval is generous, // and adding another long-lived connection is more failure modes // than it's worth. useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; const poll = async () => { try { const res = await fetch("/api/storage/status", { credentials: "include", }); if (cancelled) return; if (res.ok) { const data = (await res.json()) as StorageStatus; setServerStatus(data); } else if (res.status === 403) { // Viewer (not an editor) - storage status isn't relevant // to them. Stop polling. return; } } catch { // Network blip - keep trying. The WS disconnection will // dominate the UI anyway in that case. } if (!cancelled) { timer = setTimeout(poll, POLL_MS); } }; poll(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, []); // ---- Derive the displayed status --------------------------------------- // Worst-applicable wins (see DisplayStatus jsdoc). const status: DisplayStatus = (() => { if (serverStatus?.lastError) return "error"; if (!wsConnected) return "offline"; if (hasLocalEdit || serverStatus?.pendingPush) return "pending"; return "saved"; })(); // ---- beforeunload guard ------------------------------------------------ // If there's an unsynced local edit OR a pending push OR a known // sync error, browsers should pop the standard "Leave site?" // confirmation. The exact message is ignored by modern browsers // (Chrome/Safari/Firefox show their own generic copy) but // setting `returnValue` is what triggers the prompt. useEffect(() => { const needsGuard = status === "pending" || status === "error" || status === "offline"; if (!needsGuard) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); // Legacy browsers (and TS types still hold this) want a string. e.returnValue = ""; return ""; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [status]); // ---- Render ------------------------------------------------------------ const { icon, label, tooltip } = renderState(status, serverStatus); return ( {icon} {label} ); } function renderState(status: DisplayStatus, server: StorageStatus | null) { switch (status) { case "error": { const err = server?.lastError; const stageLabel: Record = { "dataset-create": "Cloud storage setup failed", "local-save": "Local save failed", "cloud-push": "Cloud sync failed", }; const label = err ? stageLabel[err.stage] ?? "Storage error" : "Storage error"; const hint = err?.statusCode === 403 ? " - your OAuth grant may be missing the `manage-repos` scope. Sign out and back in." : ""; const tooltip = err ? `${label}: ${err.message}${hint}` : "Storage error"; return { icon: , label, tooltip, }; } case "offline": return { icon: , label: "Offline", tooltip: "Disconnected - reconnecting...", }; case "pending": return { icon: , label: "Saving...", tooltip: server?.datasetReady === false ? "Saving locally - cloud sync starts after first successful dataset creation" : "Saving to cloud...", }; case "saved": default: { const last = server?.lastCloudPushAt; const tooltip = last ? `All changes saved ยท last cloud sync ${formatRelative(last)}` : server?.datasetReady ? "All changes saved" : "Saved locally - cloud sync will start on first change"; return { icon: , label: "Saved", tooltip, }; } } } function formatRelative(ts: number): string { const diff = Date.now() - ts; if (diff < 5_000) return "just now"; if (diff < 60_000) return `${Math.round(diff / 1000)}s ago`; if (diff < 3_600_000) return `${Math.round(diff / 60_000)}min ago`; return new Date(ts).toLocaleTimeString(); }