/** * OutgoingLinksPanel - Collapsible sidebar showing links FROM current document. * * Aesthetic: Scholarly Dusk - ancient manuscript margins with ink annotations. * - Teal primary (#4db8a8) for wiki links, old gold secondary (#d4a053) for md links * - Broken links shown with red/warning indicator * - Collapsible with elegant reveal animation */ import { AlertTriangleIcon, ChevronDownIcon, ExternalLinkIcon, FileTextIcon, LinkIcon, Loader2Icon, } from "lucide-react"; import { useCallback, useEffect, 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 link from the API response */ export interface OutgoingLink { targetRef: string; targetRefNorm: string; targetAnchor?: string; targetCollection?: string; linkType: "wiki" | "markdown"; linkText?: string; startLine: number; startCol: number; endLine: number; endCol: number; source: string; /** Whether target was resolved (found in index) */ resolved?: boolean; /** Resolved target document ID */ resolvedDocid?: string; /** Resolved target URI */ resolvedUri?: string; /** Resolved target title */ resolvedTitle?: string; } /** API response shape */ interface LinksResponse { links: OutgoingLink[]; meta: { docid: string; totalLinks: number; }; } export interface OutgoingLinksPanelProps { /** Document ID to fetch links for */ docId: string; /** Additional CSS classes */ className?: string; /** Whether panel starts open */ defaultOpen?: boolean; /** Callback when user clicks an internal link */ onNavigate?: (uri: string) => void; } /** Loading skeleton */ function LinksSkeleton() { return (
{[1, 2, 3].map((i) => (
))}
); } /** Empty state */ function LinksEmpty() { return (

No outgoing links

); } /** Individual link item */ function LinkItem({ link, onNavigate, }: { link: OutgoingLink; onNavigate?: (uri: string) => void; }) { const isWiki = link.linkType === "wiki"; const isBroken = link.resolved === false; // Use resolved title if available, fall back to linkText or targetRef const displayText = link.resolvedTitle || link.linkText || link.targetRef; const handleClick = () => { // Only navigate if resolved and we have target URI if (onNavigate && link.resolvedUri) { onNavigate(link.resolvedUri); } }; return ( ); } export function OutgoingLinksPanel({ docId, className, defaultOpen = true, onNavigate, }: OutgoingLinksPanelProps) { const [isOpen, setIsOpen] = useState(defaultOpen); const [links, setLinks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Fetch links for the document const fetchLinks = useCallback(async () => { if (!docId) { setLinks([]); setLoading(false); return; } setLoading(true); setError(null); const url = `/api/doc/${encodeURIComponent(docId)}/links`; const { data, error: fetchError } = await apiFetch(url); if (fetchError || !data) { setError(fetchError ?? "Failed to load links"); setLoading(false); return; } setLinks(data.links); setLoading(false); }, [docId]); // Fetch on mount and when docId changes useEffect(() => { void fetchLinks(); }, [fetchLinks]); // Count broken links const brokenCount = links.filter((l) => l.resolved === false).length; return ( {/* Header trigger */} {/* Chevron */} {/* Title */} Outgoing Links {/* Count badges */} {!loading && links.length > 0 && (
{links.length} {brokenCount > 0 && ( {brokenCount} )}
)} {/* Loading indicator */} {loading && ( )}
{/* Content */} {loading && } {!loading && error && (

{error}

)} {!loading && !error && links.length === 0 && } {!loading && !error && links.length > 0 && (
{links.map((link, idx) => ( ))}
)}
); }