diff --git a/surfsense_web/components/tool-ui/citation/_adapter.tsx b/surfsense_web/components/tool-ui/citation/_adapter.tsx deleted file mode 100644 index 06ee62f6f..000000000 --- a/surfsense_web/components/tool-ui/citation/_adapter.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"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 deleted file mode 100644 index 711acfcf7..000000000 --- a/surfsense_web/components/tool-ui/citation/citation-list.tsx +++ /dev/null @@ -1,464 +0,0 @@ -"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 Image from "next/image"; -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 ( -
- - - - - 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 deleted file mode 100644 index 551f82a75..000000000 --- a/surfsense_web/components/tool-ui/citation/citation.tsx +++ /dev/null @@ -1,259 +0,0 @@ -"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 ? ( - - ) : ( -