"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Search, Maximize2, Pause, Play, Sparkles, SlidersHorizontal, X, ListChecks, Share2, RotateCcw } from "lucide-react"; import { api } from "@/lib/client/api"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge, Kbd, Popover, PopoverContent, PopoverTrigger, Spinner, Switch, Empty } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; import { NODE_KINDS, NODE_KIND_LABELS, type GraphData, type GraphNode, type NodeKind, type Suggestion } from "@/lib/graph/types"; import { GraphCanvas, type GraphCanvasHandle } from "./GraphCanvas"; import { Inspector } from "./Inspector"; import { SuggestionList } from "./SuggestionList"; import { KIND_CLASS } from "./colors"; const SECTIONS = ["notes", "documents", "databases", "projects", "research"]; const RANGES: { id: string; label: string; days: number | null }[] = [{ id: "all", label: "All time", days: null }, { id: "7d", label: "Last 7 days", days: 7 }, { id: "30d", label: "Last 30 days", days: 30 }, { id: "90d", label: "Last 90 days", days: 90 }, { id: "365d", label: "Last year", days: 365 }]; interface Filters { types: NodeKind[]; section: string; range: string; hideOrphans: boolean; suggestions: boolean; pageId: string | null; depth: number } const DEFAULT: Filters = { types: [...NODE_KINDS], section: "", range: "all", hideOrphans: false, suggestions: true, pageId: null, depth: 2 }; const EMPTY: GraphData = { nodes: [], edges: [], stats: { pages: 0, edges: 0, suggested: 0 } }; export function GraphExplorer({ focus }: { focus?: string | null }) { const qc = useQueryClient(); const [f, setF] = useState(() => (focus ? { ...DEFAULT, pageId: focus, depth: 2 } : DEFAULT)); const [q, setQ] = useState(""); const [selected, setSelected] = useState(focus ?? null); const [frozen, setFrozen] = useState(false); const [panel, setPanel] = useState<"inspector" | "suggestions" | null>(focus ? "inspector" : null); const [filtersOpen, setFiltersOpen] = useState(false); const canvas = useRef(null); const search = useRef(null); const params = useMemo(() => { const p = new URLSearchParams(); if (f.types.length !== NODE_KINDS.length) p.set("types", f.types.join(",")); if (f.section) p.set("section", f.section); const d = RANGES.find((x) => x.id === f.range)?.days; if (d) p.set("since", new Date(Date.now() - d * 864e5).toISOString()); if (f.pageId) { p.set("pageId", f.pageId); p.set("depth", String(f.depth)); } if (f.hideOrphans) p.set("hideOrphans", "1"); if (!f.suggestions) p.set("suggestions", "0"); return p.toString(); }, [f]); const graph = useQuery({ queryKey: ["graph", params], queryFn: () => api(`/api/graph${params ? `?${params}` : ""}`), placeholderData: keepPreviousData, staleTime: 10_000 }); const sugg = useQuery({ queryKey: ["graph-suggestions"], queryFn: () => api<{ items: Suggestion[]; counts: Record }>("/api/graph/suggestions"), staleTime: 10_000 }); const data = graph.data ?? EMPTY; const selectedNode = useMemo(() => data.nodes.find((n) => n.id === selected) ?? null, [data, selected]); const highlight = useMemo(() => { const s = q.trim().toLowerCase(); if (!s) return null; return new Set(data.nodes.filter((n) => n.title.toLowerCase().includes(s)).map((n) => n.id)); }, [q, data]); const matches = highlight?.size ?? 0; const suggest = useMutation({ mutationFn: (pageId: string | null) => api<{ queued: boolean; result?: { candidates: number; suggested: number; declined: number; skipped?: string } }>("/api/graph/suggestions", { method: "POST", json: { action: "suggest", pageId, inline: Boolean(pageId) } }), onSuccess: (d, pageId) => { if (d.queued) toast("Workspace pass queued", { description: "The worker is scanning every embedded page; suggestions will appear here as they are found.", tone: "ai" }); else { const x = d.result!; toast(x.suggested ? `${x.suggested} new suggestion${x.suggested > 1 ? "s" : ""}` : "No new suggestion", { description: x.skipped ? (x.skipped === "no candidates" ? "No similar unlinked page (or the page is not embedded yet)." : x.skipped) : `${x.candidates} candidate${x.candidates === 1 ? "" : "s"} checked, ${x.declined} declined by the model.`, tone: "ai" }); if (x.suggested) setPanel("suggestions"); } qc.invalidateQueries({ queryKey: ["graph"] }); qc.invalidateQueries({ queryKey: ["graph-suggestions"] }); void pageId; }, onError: (e: any) => toast("Suggestion failed", { description: e.message, tone: "danger" }), }); const focusNode = useCallback((id: string) => { setSelected(id); setPanel("inspector"); canvas.current?.focus(id, 2.5); }, []); const onSelect = useCallback((n: GraphNode | null) => { setSelected(n?.id ?? null); if (n) setPanel("inspector"); else if (panel === "inspector") setPanel(null); }, [panel]); useEffect(() => { const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable); if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey) { e.preventDefault(); search.current?.focus(); } else if (e.key === "Escape") { if (typing && t === search.current) { setQ(""); search.current?.blur(); } else { setSelected(null); setPanel(null); setFiltersOpen(false); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const onSearchEnter = () => { if (!highlight?.size) return; const first = data.nodes.find((n) => highlight.has(n.id)); if (first) focusNode(first.id); }; const toggleType = (k: NodeKind) => setF((s) => ({ ...s, types: s.types.includes(k) ? s.types.filter((x) => x !== k) : [...s.types, k] })); const pending = sugg.data?.items ?? []; const pendingCount = sugg.data?.counts?.suggested ?? pending.length; const filterControls = (vertical?: boolean) => (
{NODE_KINDS.map((k) => )}
{f.pageId && }
); const side = panel === "inspector" && selectedNode ? { setSelected(null); setPanel(null); }} onFocus={focusNode} onSuggest={(id) => suggest.mutate(id)} suggesting={suggest.isPending && suggest.variables === selectedNode.id} onNeighbourhood={(id) => setF((s) => ({ ...s, pageId: id, depth: 2 }))} /> : panel === "suggestions" ? (
Suggested links {pendingCount ? {pendingCount} : null}
) : null; return (
setQ(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") onSearchEnter(); }} placeholder="Search to focus…" className="h-8 pl-8 pr-14 text-[13px]" />{q ? {matches} match{matches === 1 ? "" : "es"} : /}
{filterControls(false)}
{filterControls(true)} {f.pageId && }
{graph.isLoading ?
: graph.isError ? } title="Could not load the graph" hint={(graph.error as any)?.message} action={} /> : !data.nodes.length ? } title="Nothing to show" hint={f.pageId || f.section || f.range !== "all" || f.hideOrphans || f.types.length !== NODE_KINDS.length ? "No node matches the current filters." : "Create pages and mention them with @ to grow your graph."} action={(f.pageId || f.section || f.range !== "all" || f.hideOrphans || f.types.length !== NODE_KINDS.length) ? : undefined} /> : }
{NODE_KINDS.filter((k) => f.types.includes(k)).map((k) => {NODE_KIND_LABELS[k]})}
link / relation AI suggested
{data.nodes.length} nodes · {data.edges.length} edges{data.stats.suggested ? ` · ${data.stats.suggested} suggested` : ""}{graph.isFetching && !graph.isLoading ? " · updating…" : ""}{frozen ? " · frozen" : ""}
{side && } {side &&
{side}
}
); }