mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
refactor: implement CitationHoverPopover component to enhance inline citation functionality and improve user interaction
This commit is contained in:
parent
56239548c8
commit
ea087d1d23
3 changed files with 293 additions and 210 deletions
|
|
@ -5,12 +5,11 @@ import { useSetAtom } from "jotai";
|
||||||
import { ExternalLink, FileText } from "lucide-react";
|
import { ExternalLink, FileText } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||||
import { Citation } from "@/components/tool-ui/citation";
|
import { Citation } from "@/components/tool-ui/citation";
|
||||||
|
import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
|
|
@ -31,8 +30,6 @@ interface InlineCitationProps {
|
||||||
isDocsChunk?: boolean;
|
isDocsChunk?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
|
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
|
||||||
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
|
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
|
||||||
|
|
@ -91,108 +88,80 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||||
const [open, setOpen] = useState(false);
|
return (
|
||||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
<CitationHoverPopover
|
||||||
|
id={`doc-${chunkId}`}
|
||||||
const cancelClose = useCallback(() => {
|
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
|
||||||
if (closeTimerRef.current) {
|
align="start"
|
||||||
clearTimeout(closeTimerRef.current);
|
trigger={(hoverProps) => (
|
||||||
closeTimerRef.current = null;
|
<Button
|
||||||
}
|
type="button"
|
||||||
}, []);
|
variant="ghost"
|
||||||
|
className="ml-0.5 h-5 min-w-5 cursor-pointer gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||||
const scheduleClose = useCallback(() => {
|
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
|
||||||
cancelClose();
|
title="Surfsense documentation"
|
||||||
closeTimerRef.current = setTimeout(() => {
|
{...hoverProps}
|
||||||
setOpen(false);
|
>
|
||||||
closeTimerRef.current = null;
|
<FileText className="size-3" />
|
||||||
}, POPOVER_HOVER_CLOSE_DELAY_MS);
|
doc
|
||||||
}, [cancelClose]);
|
</Button>
|
||||||
|
)}
|
||||||
useEffect(() => () => cancelClose(), [cancelClose]);
|
>
|
||||||
|
<SurfsenseDocPreview chunkId={chunkId} />
|
||||||
|
</CitationHoverPopover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
|
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
|
||||||
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
|
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
|
||||||
enabled: open,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
|
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<>
|
||||||
<PopoverTrigger asChild>
|
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||||
<Button
|
<div className="min-w-0">
|
||||||
type="button"
|
<p className="truncate text-sm font-medium">
|
||||||
variant="ghost"
|
{data?.title ?? "Surfsense documentation"}
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
</p>
|
||||||
onMouseEnter={() => {
|
<p className="text-[11px] text-muted-foreground">Chunk #{chunkId}</p>
|
||||||
cancelClose();
|
</div>
|
||||||
setOpen(true);
|
{data?.source && (
|
||||||
}}
|
<a
|
||||||
onMouseLeave={scheduleClose}
|
href={data.source}
|
||||||
onFocus={() => {
|
target="_blank"
|
||||||
cancelClose();
|
rel="noopener noreferrer"
|
||||||
setOpen(true);
|
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-primary/10"
|
||||||
}}
|
>
|
||||||
onBlur={scheduleClose}
|
<ExternalLink className="size-3" />
|
||||||
className="ml-0.5 h-5 min-w-5 cursor-pointer gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
Open
|
||||||
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
|
</a>
|
||||||
title="Surfsense documentation"
|
)}
|
||||||
>
|
</div>
|
||||||
<FileText className="size-3" />
|
<div className="max-h-72 overflow-auto px-3 py-2 text-sm">
|
||||||
doc
|
{isLoading && (
|
||||||
</Button>
|
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||||
</PopoverTrigger>
|
<Spinner size="xs" />
|
||||||
<PopoverContent
|
<span className="text-xs">Loading…</span>
|
||||||
className="w-96 max-w-[calc(100vw-2rem)] p-0"
|
|
||||||
align="start"
|
|
||||||
sideOffset={6}
|
|
||||||
onMouseEnter={cancelClose}
|
|
||||||
onMouseLeave={scheduleClose}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium">
|
|
||||||
{data?.title ?? "Surfsense documentation"}
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground">Chunk #{chunkId}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{data?.source && (
|
)}
|
||||||
<a
|
{error && (
|
||||||
href={data.source}
|
<p className="py-4 text-xs text-destructive">
|
||||||
target="_blank"
|
{error instanceof Error ? error.message : "Failed to load chunk"}
|
||||||
rel="noopener noreferrer"
|
</p>
|
||||||
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-primary/10"
|
)}
|
||||||
>
|
{!isLoading && !error && citedChunk?.content && (
|
||||||
<ExternalLink className="size-3" />
|
<MarkdownViewer content={citedChunk.content} maxLength={1500} enableCitations />
|
||||||
Open
|
)}
|
||||||
</a>
|
{!isLoading && !error && !citedChunk?.content && (
|
||||||
)}
|
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
|
||||||
</div>
|
)}
|
||||||
<div className="max-h-72 overflow-auto px-3 py-2 text-sm">
|
</div>
|
||||||
{isLoading && (
|
</>
|
||||||
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
|
||||||
<Spinner size="xs" />
|
|
||||||
<span className="text-xs">Loading…</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="py-4 text-xs text-destructive">
|
|
||||||
{error instanceof Error ? error.message : "Failed to load chunk"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!isLoading && !error && citedChunk?.content && (
|
|
||||||
<MarkdownViewer content={citedChunk.content} maxLength={1500} enableCitations />
|
|
||||||
)}
|
|
||||||
{!isLoading && !error && !citedChunk?.content && (
|
|
||||||
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||||
|
|
||||||
|
type PopoverContentProps = ComponentProps<typeof PopoverContent>;
|
||||||
|
|
||||||
|
export type CitationHoverTriggerProps = Pick<
|
||||||
|
HTMLAttributes<HTMLElement>,
|
||||||
|
"onBlur" | "onFocus" | "onPointerEnter" | "onPointerLeave"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface CitationHoverPopoverProps {
|
||||||
|
id: string;
|
||||||
|
trigger: (props: CitationHoverTriggerProps) => ReactElement;
|
||||||
|
children: ReactNode;
|
||||||
|
contentClassName?: string;
|
||||||
|
side?: PopoverContentProps["side"];
|
||||||
|
align?: PopoverContentProps["align"];
|
||||||
|
sideOffset?: PopoverContentProps["sideOffset"];
|
||||||
|
onContentClick?: PopoverContentProps["onClick"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPEN_DELAY_MS = 80;
|
||||||
|
const CLOSE_DELAY_MS = 120;
|
||||||
|
|
||||||
|
let activeCitationId: string | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function subscribe(listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot() {
|
||||||
|
return activeCitationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveCitationId(id: string | null) {
|
||||||
|
if (activeCitationId === id) return;
|
||||||
|
activeCitationId = id;
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCitationHoverState(id: string) {
|
||||||
|
const activeId = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearTimers = useCallback(() => {
|
||||||
|
if (openTimerRef.current) {
|
||||||
|
clearTimeout(openTimerRef.current);
|
||||||
|
openTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const open = activeId === id;
|
||||||
|
|
||||||
|
const scheduleOpen = useCallback(() => {
|
||||||
|
clearTimers();
|
||||||
|
openTimerRef.current = setTimeout(() => {
|
||||||
|
setActiveCitationId(id);
|
||||||
|
openTimerRef.current = null;
|
||||||
|
}, OPEN_DELAY_MS);
|
||||||
|
}, [clearTimers, id]);
|
||||||
|
|
||||||
|
const scheduleClose = useCallback(() => {
|
||||||
|
clearTimers();
|
||||||
|
closeTimerRef.current = setTimeout(() => {
|
||||||
|
if (activeCitationId === id) {
|
||||||
|
setActiveCitationId(null);
|
||||||
|
}
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}, CLOSE_DELAY_MS);
|
||||||
|
}, [clearTimers, id]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
clearTimers();
|
||||||
|
setActiveCitationId(nextOpen ? id : null);
|
||||||
|
},
|
||||||
|
[clearTimers, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimers();
|
||||||
|
if (activeCitationId === id) {
|
||||||
|
setActiveCitationId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [clearTimers, id]);
|
||||||
|
|
||||||
|
return { open, scheduleOpen, scheduleClose, handleOpenChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CitationHoverPopover({
|
||||||
|
id,
|
||||||
|
trigger,
|
||||||
|
children,
|
||||||
|
contentClassName,
|
||||||
|
side = "top",
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 6,
|
||||||
|
onContentClick,
|
||||||
|
}: CitationHoverPopoverProps) {
|
||||||
|
const { open, scheduleOpen, scheduleClose, handleOpenChange } = useCitationHoverState(id);
|
||||||
|
const hoverProps = {
|
||||||
|
onPointerEnter: scheduleOpen,
|
||||||
|
onPointerLeave: scheduleClose,
|
||||||
|
onFocus: scheduleOpen,
|
||||||
|
onBlur: scheduleClose,
|
||||||
|
} satisfies CitationHoverTriggerProps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<PopoverTrigger asChild>{trigger(hoverProps)}</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side={side}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={contentClassName}
|
||||||
|
onPointerEnter={scheduleOpen}
|
||||||
|
onPointerLeave={scheduleClose}
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onClick={onContentClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
import { ExternalLink, Globe } from "lucide-react";
|
import { ExternalLink, Globe } from "lucide-react";
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
import * as React from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
||||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
import { cn } from "./_adapter";
|
||||||
|
import { CitationHoverPopover } from "./citation-hover-popover";
|
||||||
import type { CitationVariant, SerializableCitation } from "./schema";
|
import type { CitationVariant, SerializableCitation } from "./schema";
|
||||||
import { TYPE_ICONS } from "./type-icons";
|
import { TYPE_ICONS } from "./type-icons";
|
||||||
|
|
||||||
|
|
@ -32,29 +32,6 @@ function formatDate(isoString: string, locale: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useHoverPopover(delay = 100) {
|
|
||||||
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 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 {
|
export interface CitationProps extends SerializableCitation {
|
||||||
variant?: CitationVariant;
|
variant?: CitationVariant;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -99,13 +76,6 @@ export function Citation(props: CitationProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconElement = favicon ? (
|
const iconElement = favicon ? (
|
||||||
<NextImage
|
<NextImage
|
||||||
src={favicon}
|
src={favicon}
|
||||||
|
|
@ -120,13 +90,14 @@ export function Citation(props: CitationProps) {
|
||||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||||
);
|
);
|
||||||
|
|
||||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
|
||||||
|
|
||||||
// Inline variant: compact chip with hover popover
|
// Inline variant: compact chip with hover popover
|
||||||
if (variant === "inline") {
|
if (variant === "inline") {
|
||||||
return (
|
return (
|
||||||
<Popover open={open}>
|
<CitationHoverPopover
|
||||||
<PopoverTrigger asChild>
|
id={id}
|
||||||
|
contentClassName="w-72 cursor-pointer p-0"
|
||||||
|
onContentClick={handleClick}
|
||||||
|
trigger={(hoverProps) => (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -134,8 +105,7 @@ export function Citation(props: CitationProps) {
|
||||||
data-tool-ui-id={id}
|
data-tool-ui-id={id}
|
||||||
data-slot="citation"
|
data-slot="citation"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
{...hoverProps}
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1",
|
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1",
|
||||||
"bg-muted/60 text-sm outline-none",
|
"bg-muted/60 text-sm outline-none",
|
||||||
|
|
@ -148,34 +118,73 @@ export function Citation(props: CitationProps) {
|
||||||
{iconElement}
|
{iconElement}
|
||||||
<span className="text-muted-foreground">{domain}</span>
|
<span className="text-muted-foreground">{domain}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
)}
|
||||||
<PopoverContent
|
>
|
||||||
side="top"
|
<div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors">
|
||||||
align="start"
|
<div className="flex items-start gap-2">
|
||||||
className="w-72 cursor-pointer p-0"
|
{iconElement}
|
||||||
onMouseEnter={handleMouseEnter}
|
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<div className="hover:bg-accent hover:text-accent-foreground 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>
|
</div>
|
||||||
</PopoverContent>
|
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||||
</Popover>
|
{snippet && (
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{snippet}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CitationHoverPopover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardClassName = 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 no-underline",
|
||||||
|
"hover:border-foreground/25",
|
||||||
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardContent = (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{snippet && (
|
||||||
|
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||||
|
<span className="line-clamp-3">{snippet}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Default variant: full card
|
// Default variant: full card
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
|
@ -184,59 +193,22 @@ export function Citation(props: CitationProps) {
|
||||||
data-tool-ui-id={id}
|
data-tool-ui-id={id}
|
||||||
data-slot="citation"
|
data-slot="citation"
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
|
{sanitizedHref ? (
|
||||||
<div
|
<a
|
||||||
className={cn(
|
href={sanitizedHref}
|
||||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
target="_blank"
|
||||||
"border-border bg-card border text-sm shadow-xs",
|
rel="noopener noreferrer"
|
||||||
"transition-colors duration-150",
|
className={cardClassName}
|
||||||
sanitizedHref && [
|
onClick={(event) => {
|
||||||
"cursor-pointer",
|
event.preventDefault();
|
||||||
"hover:border-foreground/25",
|
handleClick();
|
||||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
}}
|
||||||
]
|
>
|
||||||
)}
|
{cardContent}
|
||||||
onClick={sanitizedHref ? handleClick : undefined}
|
</a>
|
||||||
role={sanitizedHref ? "link" : undefined}
|
) : (
|
||||||
tabIndex={sanitizedHref ? 0 : undefined}
|
<div className={cardClassName}>{cardContent}</div>
|
||||||
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>
|
|
||||||
|
|
||||||
{snippet && (
|
|
||||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
|
||||||
<span className="line-clamp-3">{snippet}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue