diff --git a/surfsense_web/components/tool-ui/citation/_adapter.tsx b/surfsense_web/components/tool-ui/citation/_adapter.tsx new file mode 100644 index 000000000..06ee62f6f --- /dev/null +++ b/surfsense_web/components/tool-ui/citation/_adapter.tsx @@ -0,0 +1,8 @@ +"use client"; + +export { cn } from "@/lib/utils"; +export { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; diff --git a/surfsense_web/components/tool-ui/citation/citation-list.tsx b/surfsense_web/components/tool-ui/citation/citation-list.tsx new file mode 100644 index 000000000..34b995aae --- /dev/null +++ b/surfsense_web/components/tool-ui/citation/citation-list.tsx @@ -0,0 +1,463 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; +import { Citation } from "./citation"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; +import { + openSafeNavigationHref, + resolveSafeNavigationHref, +} from "../shared/media"; + +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)} + /> + ))} +
+
+
+
+ ); +} diff --git a/surfsense_web/components/tool-ui/citation/citation.tsx b/surfsense_web/components/tool-ui/citation/citation.tsx new file mode 100644 index 000000000..dcecb7fa3 --- /dev/null +++ b/surfsense_web/components/tool-ui/citation/citation.tsx @@ -0,0 +1,261 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; + +import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function extractDomain(url: string): string | undefined { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function formatDate(isoString: string, locale: string): string { + try { + const date = new Date(isoString); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + }); + } catch { + return isoString; + } +} + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(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]); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { open, setOpen, handleMouseEnter, handleMouseLeave }; +} + +export interface CitationProps extends SerializableCitation { + variant?: CitationVariant; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function Citation(props: CitationProps) { + const { variant = "default", className, onNavigate, ...serializable } = props; + + const { + id, + href: rawHref, + title, + snippet, + domain: providedDomain, + favicon, + author, + publishedAt, + type = "webpage", + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + const sanitizedHref = sanitizeHref(rawHref); + const domain = providedDomain ?? extractDomain(rawHref); + + const citationData: SerializableCitation = { + ...serializable, + href: sanitizedHref ?? rawHref, + domain, + locale, + }; + + const TypeIcon = TYPE_ICONS[type] ?? Globe; + + const handleClick = () => { + if (!sanitizedHref) return; + if (onNavigate) { + onNavigate(sanitizedHref, citationData); + } else { + openSafeNavigationHref(sanitizedHref); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (sanitizedHref && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleClick(); + } + }; + + const iconElement = favicon ? ( + // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config + + ) : ( +