"use client"; import type { LucideIcon } from "lucide-react"; import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react"; import NextImage from "next/image"; import * as React from "react"; import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media"; import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; import { Citation } from "./citation"; import type { CitationType, CitationVariant, SerializableCitation } from "./schema"; const TYPE_ICONS: Record = { webpage: Globe, document: FileText, article: Newspaper, api: Database, code: Code2, other: File, }; function useHoverPopover(delay = 100) { const [open, setOpen] = React.useState(false); const timeoutRef = React.useRef | null>(null); const containerRef = React.useRef(null); const handleMouseEnter = React.useCallback(() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setOpen(true), delay); }, [delay]); const handleMouseLeave = React.useCallback(() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setOpen(false), delay); }, [delay]); const handleFocus = React.useCallback(() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setOpen(true); }, []); const handleBlur = React.useCallback( (e: React.FocusEvent) => { const relatedTarget = e.relatedTarget as HTMLElement | null; if (containerRef.current?.contains(relatedTarget)) { return; } if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) { return; } if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setOpen(false), delay); }, [delay] ); React.useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); return { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleFocus, handleBlur, }; } export interface CitationListProps { id: string; citations: SerializableCitation[]; variant?: CitationVariant; maxVisible?: number; className?: string; onNavigate?: (href: string, citation: SerializableCitation) => void; } export function CitationList(props: CitationListProps) { const { id, citations, variant = "default", maxVisible, className, onNavigate } = props; const shouldTruncate = maxVisible !== undefined && citations.length > maxVisible; const visibleCitations = shouldTruncate ? citations.slice(0, maxVisible) : citations; const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : []; const overflowCount = overflowCitations.length; const wrapperClass = variant === "inline" ? "flex flex-wrap items-center gap-1.5" : "flex flex-col gap-2"; // Stacked variant: overlapping favicons with popover if (variant === "stacked") { return ( ); } if (variant === "default") { return (
{visibleCitations.map((citation) => ( ))} {shouldTruncate && ( )}
); } return (
{visibleCitations.map((citation) => ( ))} {shouldTruncate && ( )}
); } interface OverflowIndicatorProps { citations: SerializableCitation[]; count: number; variant: CitationVariant; onNavigate?: (href: string, citation: SerializableCitation) => void; } function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) { const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); const handleClick = (citation: SerializableCitation) => { const href = resolveSafeNavigationHref(citation.href); if (!href) return; if (onNavigate) { onNavigate(href, citation); } else { openSafeNavigationHref(href); } }; const popoverContent = (
{citations.map((citation) => ( handleClick(citation)} /> ))}
); if (variant === "inline") { return ( e.preventDefault()} > {popoverContent} ); } // Default variant return ( e.preventDefault()} > {popoverContent} ); } interface OverflowItemProps { citation: SerializableCitation; onClick: () => void; } function OverflowItem({ citation, onClick }: OverflowItemProps) { const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; return ( ); } interface StackedCitationsProps { id: string; citations: SerializableCitation[]; className?: string; onNavigate?: (href: string, citation: SerializableCitation) => void; } function StackedCitations({ id, citations, className, onNavigate }: StackedCitationsProps) { const { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleBlur } = useHoverPopover(); const maxIcons = 4; const visibleCitations = citations.slice(0, maxIcons); const remainingCount = Math.max(0, citations.length - maxIcons); const handleClick = (citation: SerializableCitation) => { const href = resolveSafeNavigationHref(citation.href); if (!href) return; if (onNavigate) { onNavigate(href, citation); } else { openSafeNavigationHref(href); } }; return ( // biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
setOpen(false)} >
{citations.map((citation) => ( handleClick(citation)} /> ))}
); }