import { useEffect, useState, useCallback } from "react"; import * as Y from "yjs"; import * as awarenessProtocol from "y-protocols/awareness"; import type { YjsState } from "~/lib/use-yjs"; import { DEFAULT_PROFILE, clearRouteData, getProfile, setGeojson } from "~/lib/route-data"; // --- localStorage helpers --- function loadBool(key: string, fallback: boolean): boolean { try { return localStorage.getItem(key) === "1"; } catch { return fallback; } } function saveBool(key: string, value: boolean) { try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* localStorage unavailable */ } } // --- Debug state --- interface DebugState { waypoints: Array>; routeData: Record; awareness: Array<{ clientId: number; state: Record }>; docSize: number; } function serializeYMap(yMap: Y.Map): Record { const obj: Record = {}; yMap.forEach((value, key) => { if (value instanceof Y.Map) { obj[key] = serializeYMap(value); } else if (value instanceof Y.Array) { obj[key] = `Y.Array(${value.length})`; } else if (typeof value === "string" && value.length > 200) { obj[key] = value.slice(0, 200) + `... (${value.length} chars)`; } else { obj[key] = value; } }); return obj; } function getDebugState(yjs: YjsState): DebugState { const waypoints = yjs.waypoints.toArray().map((yMap) => serializeYMap(yMap)); const routeData: Record = {}; yjs.routeData.forEach((value, key) => { if (typeof value === "string" && value.length > 100) { routeData[key] = `string(${value.length} chars)`; } else { routeData[key] = value; } }); const awareness: DebugState["awareness"] = []; yjs.awareness.getStates().forEach((state, clientId) => { awareness.push({ clientId, state: state as Record }); }); const docSize = Y.encodeStateAsUpdate(yjs.doc).byteLength; return { waypoints, routeData, awareness, docSize }; } // --- Styles --- const btnClass = "rounded bg-gray-700 px-2 py-0.5 text-[10px] hover:bg-gray-600 active:bg-gray-500 transition-colors"; const dangerBtnClass = "rounded bg-red-900 px-2 py-0.5 text-[10px] text-red-200 hover:bg-red-800 active:bg-red-700 transition-colors"; // --- Component --- /** * Yjs debug panel with: * - Visibility: shown in dev by default, toggle with Shift+Cmd+Option+T anywhere * - Expanded state: persisted in localStorage * - Actions: reset session, refetch route, become host, kick users * - State inspector: awareness, waypoints, route data, doc stats */ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: string }) { const [visible, setVisible] = useState(() => loadBool("trails:debug", import.meta.env.DEV)); const [expanded, setExpanded] = useState(() => loadBool("trails:debug:expanded", false)); const [state, setState] = useState(null); // Hotkey: Shift+Cmd+Option+T useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.shiftKey && e.metaKey && e.altKey && e.code === "KeyT") { e.preventDefault(); setVisible((v) => { const next = !v; saveBool("trails:debug", next); return next; }); } }; document.addEventListener("keydown", handler, true); return () => document.removeEventListener("keydown", handler, true); }, []); // Sync Yjs state useEffect(() => { if (!visible) return; const update = () => setState(getDebugState(yjs)); yjs.waypoints.observe(update); yjs.routeData.observe(update); yjs.awareness.on("change", update); update(); const interval = setInterval(update, 2000); return () => { yjs.waypoints.unobserve(update); yjs.routeData.unobserve(update); yjs.awareness.off("change", update); clearInterval(interval); }; }, [yjs, visible]); // Actions const resetSession = useCallback(() => { yjs.doc.transact(() => { while (yjs.waypoints.length > 0) { yjs.waypoints.delete(0, 1); } // Nested transact joins this transaction clearRouteData(yjs.doc, yjs.routeData); }); }, [yjs]); const refetchRoute = useCallback(async () => { const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ lat: yMap.get("lat") as number, lon: yMap.get("lon") as number, })); if (waypoints.length < 2) return; const profile = getProfile(yjs.routeData) ?? DEFAULT_PROFILE; const response = await fetch("/api/route", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ waypoints, profile, sessionId }), }); if (response.ok) { const geojson = await response.json(); setGeojson(yjs.routeData, geojson); } }, [yjs, sessionId]); const kickUser = useCallback( (clientId: number) => { if (clientId === yjs.awareness.clientID) return; awarenessProtocol.removeAwarenessStates(yjs.awareness, [clientId], "kicked"); }, [yjs], ); const toggleExpanded = useCallback(() => { setExpanded((v) => { const next = !v; saveBool("trails:debug:expanded", next); return next; }); }, []); // Render if (!visible || !state) return null; return (
{expanded && (
{state.awareness.map((a) => (
client:{a.clientId} {a.clientId === yjs.awareness.clientID && ( (you) )} {Object.keys(a.state).length === 0 && ( (server) )} {a.clientId !== yjs.awareness.clientID && Object.keys(a.state).length > 0 && ( )}
{JSON.stringify(a.state, null, 1)}
))}
{state.waypoints.map((wp, i) => (
[{i}] {JSON.stringify(wp)}
))} {state.waypoints.length === 0 && empty}
{JSON.stringify(state.routeData, null, 1)}
Size: {(state.docSize / 1024).toFixed(1)} KB
)}
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
); }