/**
* 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 (
);
}
/** 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) => (
))}
)}
);
}