"use client"; import Link from "next/link"; import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link2, Search, Sparkles, ArrowRight, EyeOff, Share2 } from "lucide-react"; import { api } from "@/lib/client/api"; import { cn, timeAgo } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge, Empty, Spinner, Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; import type { BacklinkRow, Suggestion, UnlinkedMention } from "@/lib/graph/types"; import { SuggestionList } from "./SuggestionList"; const ICON: Record = { database: "🗂", research: "🔬" }; function Highlight({ text, term }: { text: string; term: string }) { const i = text.toLowerCase().indexOf(term.toLowerCase()); if (i < 0) return <>{text}; return <>{text.slice(0, i)}{text.slice(i, i + term.length)}{text.slice(i + term.length)}; } export function BacklinksExplorer() { const qc = useQueryClient(); const [q, setQ] = useState(""); const [tab, setTab] = useState("pages"); const data = useQuery({ queryKey: ["backlinks"], queryFn: () => api<{ pages: BacklinkRow[]; mentions: UnlinkedMention[] }>("/api/graph/backlinks"), staleTime: 10_000 }); const sugg = useQuery({ queryKey: ["graph-suggestions"], queryFn: () => api<{ items: Suggestion[]; counts: Record }>("/api/graph/suggestions"), staleTime: 10_000 }); const pages = useMemo(() => { const s = q.trim().toLowerCase(); const all = data.data?.pages ?? []; return s ? all.filter((p) => p.title.toLowerCase().includes(s)) : all; }, [data.data, q]); const mentions = useMemo(() => { const s = q.trim().toLowerCase(); const all = data.data?.mentions ?? []; return s ? all.filter((m) => m.toTitle.toLowerCase().includes(s) || m.fromTitle.toLowerCase().includes(s)) : all; }, [data.data, q]); const act = useMutation({ mutationFn: (v: { action: "link" | "dismiss"; fromId: string; toId: string }) => api("/api/graph/backlinks", { method: "POST", json: v }), onSuccess: (_d, v) => { toast(v.action === "link" ? "Link inserted" : "Mention dismissed", { description: v.action === "link" ? "A “See also” mention was appended to the page." : undefined, tone: v.action === "link" ? "success" : "neutral" }); qc.invalidateQueries({ queryKey: ["backlinks"] }); qc.invalidateQueries({ queryKey: ["graph"] }); }, onError: (e: any) => toast("Action failed", { description: e.message, tone: "danger" }), }); const totals = useMemo(() => { const all = data.data?.pages ?? []; return { links: all.reduce((a, p) => a + p.incoming, 0), orphans: all.filter((p) => p.incoming === 0 && p.outgoing === 0).length }; }, [data.data]); const pendingSugg = sugg.data?.items ?? []; return (

Backlinks

{data.data ? `${data.data.pages.length} pages · ${totals.links} links · ${totals.orphans} without any link · ${data.data.mentions.length} unlinked mention${data.data.mentions.length === 1 ? "" : "s"}` : "Loading…"}

setQ(e.target.value)} placeholder="Filter by title…" className="h-8 pl-8 text-[13px]" />
PagesUnlinked mentions {mentions.length ? {mentions.length} : null}AI suggestions {pendingSugg.length ? {pendingSugg.length} : null} {data.isLoading ?
: !pages.length ? } title="No page" hint={q ? "No title matches the filter." : "Create pages to see them here."} /> : (
{pages.map((p) => ( ))}
PageSectionIncomingOutgoingAIUpdated
{p.icon ?? ICON[p.type] ?? "📄"}{p.title} {p.section} {p.incoming} {p.outgoing} {p.suggested ? {p.suggested} : –} {timeAgo(p.updatedAt)}
)}
{data.isLoading ?
: !mentions.length ? } title="No unlinked mention" hint="Every page whose text contains another page's exact title already links to it (or was dismissed)." /> : (
{mentions.map((m) => (
{m.fromIcon ?? "đź“„"} {m.fromTitle}mentions{m.toIcon ?? "đź“„"} {m.toTitle}

……

))}
)}
{!!pendingSugg.length &&

Accepted suggestions become real links and show up as backlinks on both pages.

}
); }