Spaces:
Runtime error
Runtime error
| 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<StorageStatus | null>(null); | |
| const editTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| // ---- WS layer: connection + edit activity ------------------------------ | |
| // Same lazy-provider polling pattern as the previous version: the | |
| // provider is created by <Editor> AFTER this component mounts, so a | |
| // useEffect([providerRef]) alone would never re-fire when it lands. | |
| useEffect(() => { | |
| let pollId: ReturnType<typeof setInterval> | 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<typeof setTimeout> | 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 ( | |
| <Tooltip title={tooltip}> | |
| <span | |
| className={`sync-indicator sync-indicator--${status}`} | |
| role="status" | |
| aria-live="polite" | |
| > | |
| {icon} | |
| <span className="sync-indicator__label">{label}</span> | |
| </span> | |
| </Tooltip> | |
| ); | |
| } | |
| function renderState(status: DisplayStatus, server: StorageStatus | null) { | |
| switch (status) { | |
| case "error": { | |
| const err = server?.lastError; | |
| const stageLabel: Record<string, string> = { | |
| "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: <AlertTriangle size={14} />, | |
| label, | |
| tooltip, | |
| }; | |
| } | |
| case "offline": | |
| return { | |
| icon: <CloudOff size={14} />, | |
| label: "Offline", | |
| tooltip: "Disconnected - reconnecting...", | |
| }; | |
| case "pending": | |
| return { | |
| icon: <Loader2 size={14} className="spin" />, | |
| 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: <Cloud size={14} />, | |
| 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(); | |
| } | |