mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-02 20:32:39 +02:00
chore: ran linting
This commit is contained in:
parent
74826b3714
commit
04691d572b
61 changed files with 1962 additions and 1516 deletions
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
export { cn } from "@/lib/utils";
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
export { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,463 +1,395 @@
|
|||
"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 { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import { Citation } from "./citation";
|
||||
import type {
|
||||
SerializableCitation,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
} from "./schema";
|
||||
import {
|
||||
openSafeNavigationHref,
|
||||
resolveSafeNavigationHref,
|
||||
} from "../shared/media";
|
||||
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||
|
||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||
webpage: Globe,
|
||||
document: FileText,
|
||||
article: Newspaper,
|
||||
api: Database,
|
||||
code: Code2,
|
||||
other: File,
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(true), delay);
|
||||
}, [delay]);
|
||||
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 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 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],
|
||||
);
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
containerRef,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
};
|
||||
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;
|
||||
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 { 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 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";
|
||||
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 (
|
||||
<StackedCitations
|
||||
id={id}
|
||||
citations={citations}
|
||||
className={className}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Stacked variant: overlapping favicons with popover
|
||||
if (variant === "stacked") {
|
||||
return (
|
||||
<StackedCitations
|
||||
id={id}
|
||||
citations={citations}
|
||||
className={className}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "default") {
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate flex flex-col gap-4", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation
|
||||
key={citation.id}
|
||||
{...citation}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (variant === "default") {
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate flex flex-col gap-4", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation key={citation.id} {...citation} variant="default" onNavigate={onNavigate} />
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate", wrapperClass, className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation
|
||||
key={citation.id}
|
||||
{...citation}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate", wrapperClass, className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation key={citation.id} {...citation} variant={variant} onNavigate={onNavigate} />
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverflowIndicatorProps {
|
||||
citations: SerializableCitation[];
|
||||
count: number;
|
||||
variant: CitationVariant;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
citations: SerializableCitation[];
|
||||
count: number;
|
||||
variant: CitationVariant;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
function OverflowIndicator({
|
||||
citations,
|
||||
count,
|
||||
variant,
|
||||
onNavigate,
|
||||
}: OverflowIndicatorProps) {
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
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 handleClick = (citation: SerializableCitation) => {
|
||||
const href = resolveSafeNavigationHref(citation.href);
|
||||
if (!href) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(href, citation);
|
||||
} else {
|
||||
openSafeNavigationHref(href);
|
||||
}
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const popoverContent = (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem key={citation.id} citation={citation} onClick={() => handleClick(citation)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm tabular-nums",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">+{count} more</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm tabular-nums",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">+{count} more</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-xl px-4 py-3",
|
||||
"border-border bg-card border border-dashed",
|
||||
"transition-colors duration-150",
|
||||
"hover:border-foreground/25 hover:bg-muted/50",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
+{count} more sources
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
// Default variant
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-xl px-4 py-3",
|
||||
"border-border bg-card border border-dashed",
|
||||
"transition-colors duration-150",
|
||||
"hover:border-foreground/25 hover:bg-muted/50",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">+{count} more sources</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverflowItemProps {
|
||||
citation: SerializableCitation;
|
||||
onClick: () => void;
|
||||
citation: SerializableCitation;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function OverflowItem({ citation, onClick }: OverflowItemProps) {
|
||||
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{citation.domain}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
|
||||
</div>
|
||||
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StackedCitationsProps {
|
||||
id: string;
|
||||
citations: SerializableCitation[];
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
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
|
||||
<div ref={containerRef} onBlur={handleBlur} className="inline-flex">
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
|
||||
"bg-muted/40 outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted/70",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{visibleCitations.map((citation, index) => {
|
||||
const TypeIcon =
|
||||
TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
return (
|
||||
<div
|
||||
key={citation.id}
|
||||
className={cn(
|
||||
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
|
||||
index > 0 && "-ml-2",
|
||||
)}
|
||||
style={{ zIndex: maxIcons - index }}
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon
|
||||
className="text-muted-foreground size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
|
||||
•••
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{citations.length} source{citations.length !== 1 && "s"}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onBlur={handleBlur}
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
|
||||
<div ref={containerRef} onBlur={handleBlur} className="inline-flex">
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
|
||||
"bg-muted/40 outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted/70",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{visibleCitations.map((citation, index) => {
|
||||
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
return (
|
||||
<div
|
||||
key={citation.id}
|
||||
className={cn(
|
||||
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
|
||||
index > 0 && "-ml-2"
|
||||
)}
|
||||
style={{ zIndex: maxIcons - index }}
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
|
||||
•••
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{citations.length} source{citations.length !== 1 && "s"}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onBlur={handleBlur}
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,261 +1,248 @@
|
|||
"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 { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
||||
import type {
|
||||
SerializableCitation,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
} from "./schema";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||
|
||||
const FALLBACK_LOCALE = "en-US";
|
||||
|
||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||
webpage: Globe,
|
||||
document: FileText,
|
||||
article: Newspaper,
|
||||
api: Database,
|
||||
code: Code2,
|
||||
other: File,
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(true), delay);
|
||||
}, [delay]);
|
||||
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 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);
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { open, setOpen, handleMouseEnter, handleMouseLeave };
|
||||
return { open, setOpen, handleMouseEnter, handleMouseLeave };
|
||||
}
|
||||
|
||||
export interface CitationProps extends SerializableCitation {
|
||||
variant?: CitationVariant;
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
variant?: CitationVariant;
|
||||
className?: string;
|
||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||
}
|
||||
|
||||
export function Citation(props: CitationProps) {
|
||||
const { variant = "default", className, onNavigate, ...serializable } = props;
|
||||
const { variant = "default", className, onNavigate, ...serializable } = props;
|
||||
|
||||
const {
|
||||
id,
|
||||
href: rawHref,
|
||||
title,
|
||||
snippet,
|
||||
domain: providedDomain,
|
||||
favicon,
|
||||
author,
|
||||
publishedAt,
|
||||
type = "webpage",
|
||||
locale: providedLocale,
|
||||
} = serializable;
|
||||
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 locale = providedLocale ?? FALLBACK_LOCALE;
|
||||
const sanitizedHref = sanitizeHref(rawHref);
|
||||
const domain = providedDomain ?? extractDomain(rawHref);
|
||||
|
||||
const citationData: SerializableCitation = {
|
||||
...serializable,
|
||||
href: sanitizedHref ?? rawHref,
|
||||
domain,
|
||||
locale,
|
||||
};
|
||||
const citationData: SerializableCitation = {
|
||||
...serializable,
|
||||
href: sanitizedHref ?? rawHref,
|
||||
domain,
|
||||
locale,
|
||||
};
|
||||
|
||||
const TypeIcon = TYPE_ICONS[type] ?? Globe;
|
||||
const TypeIcon = TYPE_ICONS[type] ?? Globe;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!sanitizedHref) return;
|
||||
if (onNavigate) {
|
||||
onNavigate(sanitizedHref, citationData);
|
||||
} else {
|
||||
openSafeNavigationHref(sanitizedHref);
|
||||
}
|
||||
};
|
||||
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 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
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={14}
|
||||
height={14}
|
||||
className="bg-muted size-3.5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
const iconElement = favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={14}
|
||||
height={14}
|
||||
className="bg-muted size-3.5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
// Inline variant: compact chip with hover popover
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 cursor-pointer p-0"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
// Inline variant: compact chip with hover popover
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 cursor-pointer p-0"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant: full card
|
||||
return (
|
||||
<article
|
||||
className={cn("relative w-full max-w-md min-w-72", className)}
|
||||
lang={locale}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"transition-colors duration-150",
|
||||
sanitizedHref && [
|
||||
"cursor-pointer",
|
||||
"hover:border-foreground/25",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
],
|
||||
)}
|
||||
onClick={sanitizedHref ? handleClick : undefined}
|
||||
role={sanitizedHref ? "link" : undefined}
|
||||
tabIndex={sanitizedHref ? 0 : undefined}
|
||||
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{iconElement}
|
||||
<span className="truncate font-medium">{domain}</span>
|
||||
{(author || publishedAt) && (
|
||||
<span className="opacity-70">
|
||||
<span className="opacity-60"> — </span>
|
||||
{author}
|
||||
{author && publishedAt && ", "}
|
||||
{publishedAt && (
|
||||
<time dateTime={publishedAt} className="tabular-nums">
|
||||
{formatDate(publishedAt, locale)}
|
||||
</time>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sanitizedHref && (
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
// Default variant: full card
|
||||
return (
|
||||
<article
|
||||
className={cn("relative w-full max-w-md min-w-72", className)}
|
||||
lang={locale}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"transition-colors duration-150",
|
||||
sanitizedHref && [
|
||||
"cursor-pointer",
|
||||
"hover:border-foreground/25",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
]
|
||||
)}
|
||||
onClick={sanitizedHref ? handleClick : undefined}
|
||||
role={sanitizedHref ? "link" : undefined}
|
||||
tabIndex={sanitizedHref ? 0 : undefined}
|
||||
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{iconElement}
|
||||
<span className="truncate font-medium">{domain}</span>
|
||||
{(author || publishedAt) && (
|
||||
<span className="opacity-70">
|
||||
<span className="opacity-60"> — </span>
|
||||
{author}
|
||||
{author && publishedAt && ", "}
|
||||
{publishedAt && (
|
||||
<time dateTime={publishedAt} className="tabular-nums">
|
||||
{formatDate(publishedAt, locale)}
|
||||
</time>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sanitizedHref && (
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
|
||||
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
|
||||
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||
<span className="line-clamp-3">{snippet}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||
<span className="line-clamp-3">{snippet}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export { Citation } from "./citation";
|
||||
export type { CitationProps } from "./citation";
|
||||
export { CitationList } from "./citation-list";
|
||||
export { Citation } from "./citation";
|
||||
export type { CitationListProps } from "./citation-list";
|
||||
export { CitationList } from "./citation-list";
|
||||
export type {
|
||||
SerializableCitation,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
SerializableCitation,
|
||||
} from "./schema";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
import { ToolUIIdSchema, ToolUIReceiptSchema, ToolUIRoleSchema } from "../shared/schema";
|
||||
|
||||
export const CitationTypeSchema = z.enum([
|
||||
"webpage",
|
||||
"document",
|
||||
"article",
|
||||
"api",
|
||||
"code",
|
||||
"other",
|
||||
"webpage",
|
||||
"document",
|
||||
"article",
|
||||
"api",
|
||||
"code",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export type CitationType = z.infer<typeof CitationTypeSchema>;
|
||||
|
|
@ -21,18 +17,18 @@ export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
|
|||
export type CitationVariant = z.infer<typeof CitationVariantSchema>;
|
||||
|
||||
export const SerializableCitationSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
href: z.string().url(),
|
||||
title: z.string(),
|
||||
snippet: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
favicon: z.string().url().optional(),
|
||||
author: z.string().optional(),
|
||||
publishedAt: z.string().datetime().optional(),
|
||||
type: CitationTypeSchema.optional(),
|
||||
locale: z.string().optional(),
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
href: z.string().url(),
|
||||
title: z.string(),
|
||||
snippet: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
favicon: z.string().url().optional(),
|
||||
author: z.string().optional(),
|
||||
publishedAt: z.string().datetime().optional(),
|
||||
type: CitationTypeSchema.optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue