| 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"; |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| type DisplayStatus = |
| | "saved" |
| | "pending" |
| | "offline" |
| | "error"; |
|
|
| 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); |
|
|
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| const seed = () => { |
| const ws = (p as any).configuration?.websocketProvider; |
| setWsConnected(ws?.status === "connected"); |
| }; |
| seed(); |
| const reconcile = setInterval(seed, 1000); |
|
|
| |
| |
| |
| const ydoc = (p as any).document as |
| | { on: Function; off: Function } |
| | undefined; |
| const onYUpdate = (_u: Uint8Array, origin: unknown) => { |
| if (origin === p) return; |
| setHasLocalEdit(true); |
| if (editTimerRef.current) clearTimeout(editTimerRef.current); |
| |
| |
| |
| |
| 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]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| |
| return; |
| } |
| } catch { |
| |
| |
| } |
| if (!cancelled) { |
| timer = setTimeout(poll, POLL_MS); |
| } |
| }; |
| poll(); |
|
|
| return () => { |
| cancelled = true; |
| if (timer) clearTimeout(timer); |
| }; |
| }, []); |
|
|
| |
| |
| const status: DisplayStatus = (() => { |
| if (serverStatus?.lastError) return "error"; |
| if (!wsConnected) return "offline"; |
| if (hasLocalEdit || serverStatus?.pendingPush) return "pending"; |
| return "saved"; |
| })(); |
|
|
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| const needsGuard = status === "pending" || status === "error" || status === "offline"; |
| if (!needsGuard) return; |
|
|
| const handler = (e: BeforeUnloadEvent) => { |
| e.preventDefault(); |
| |
| e.returnValue = ""; |
| return ""; |
| }; |
| window.addEventListener("beforeunload", handler); |
| return () => window.removeEventListener("beforeunload", handler); |
| }, [status]); |
|
|
| |
| 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(); |
| } |
|
|