/** * BacklinksPanel - Collapsible sidebar showing docs linking TO current document. * * Aesthetic: Scholarly index card catalog / cross-reference slips * - Each backlink as a miniature index card with brass accents * - Context snippets shown as highlighted marginalia * - Brass connecting lines suggest knowledge graph web * - Specimen cabinet drawer feel from TagFacets extended here */ import { ChevronDownIcon, ChevronRightIcon, LinkIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "./ui/collapsible"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; /** Single backlink from API */ interface Backlink { sourceDocid: string; sourceUri: string; sourceTitle?: string; linkText?: string; startLine: number; startCol: number; } /** API response shape */ interface BacklinksResponse { backlinks: Backlink[]; meta: { docid: string; totalBacklinks: number; }; } export interface BacklinksPanelProps { /** Current document ID to fetch backlinks for */ docId: string; /** Additional CSS classes */ className?: string; /** Initial collapsed state */ defaultOpen?: boolean; /** Navigate to source doc callback */ onNavigate?: (uri: string) => void; } /** Simple in-memory cache with TTL */ interface CacheEntry { data: BacklinksResponse; timestamp: number; } const cache = new Map(); const CACHE_TTL = 30000; // 30 seconds function getCached(key: string): BacklinksResponse | null { const entry = cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp > CACHE_TTL) { cache.delete(key); return null; } return entry.data; } function setCache(key: string, data: BacklinksResponse): void { cache.set(key, { data, timestamp: Date.now() }); } /** Loading skeleton - index cards shimmer with brass tint */ function BacklinksSkeleton() { return (
{[1, 2, 3].map((i) => (
{/* Title line - teal shimmer */}
{/* Context snippet - subtle brass */}
))}
); } /** Empty state - no references found */ function BacklinksEmpty() { return (
{/* Brass-ringed empty indicator */}

No backlinks found

Other documents haven't linked to this one yet

); } /** Individual backlink item - matches OutgoingLinksPanel row style */ function BacklinkItem({ backlink, onNavigate, }: { backlink: Backlink; onNavigate?: (uri: string) => void; }) { // Extract filename from URI for display const displayName = backlink.sourceTitle || backlink.sourceUri.split("/").pop() || "Untitled"; return ( ); } export function BacklinksPanel({ docId, className, defaultOpen = true, onNavigate, }: BacklinksPanelProps) { const [isOpen, setIsOpen] = useState(defaultOpen); const [backlinks, setBacklinks] = useState([]); const [totalCount, setTotalCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Request sequencing - track latest request to ignore stale responses const requestIdRef = useRef(0); // Cache key for this doc const cacheKey = `backlinks:${docId}`; // Fetch backlinks const fetchBacklinks = useCallback(async () => { // Increment request ID for sequencing const currentRequestId = ++requestIdRef.current; // Check cache first const cached = getCached(cacheKey); if (cached) { setBacklinks(cached.backlinks); setTotalCount(cached.meta.totalBacklinks); setLoading(false); return; } setLoading(true); setError(null); const url = `/api/doc/${encodeURIComponent(docId)}/backlinks`; const { data, error: fetchError } = await apiFetch(url); // Ignore stale response if newer request was made if (currentRequestId !== requestIdRef.current) { return; } if (fetchError || !data) { setError(fetchError ?? "Failed to load backlinks"); setLoading(false); return; } // Cache and update state setCache(cacheKey, data); setBacklinks(data.backlinks); setTotalCount(data.meta.totalBacklinks); setLoading(false); }, [cacheKey, docId]); // Fetch on mount and when docId changes useEffect(() => { void fetchBacklinks(); }, [fetchBacklinks]); return ( {/* Panel header */} {/* Chevron */} {isOpen ? ( ) : ( )} {/* Title */} Backlinks {/* Count badge */} {!loading && ( 0 ? "bg-primary/12 text-primary" : "bg-muted/20 text-muted-foreground/60" )} > {totalCount} )} {/* Loading indicator */} {loading && ( )} {/* Content area */} {loading ? ( ) : error ? (

{error}

) : backlinks.length === 0 ? ( ) : (
{backlinks.map((bl) => ( ))}
)}
); }