mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
refactor: enhance citation components with mobile support and improved styling for better user experience
This commit is contained in:
parent
01d7379914
commit
4dd5871318
4 changed files with 92 additions and 53 deletions
|
|
@ -5,13 +5,22 @@ import { useSetAtom } from "jotai";
|
|||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
|
|
@ -51,7 +60,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm"
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
|
||||
role="note"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
|
|
@ -78,7 +87,7 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
|||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => openCitationPanel({ chunkId })}
|
||||
className="ml-0.5 h-5 min-w-5 cursor-pointer rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
aria-label={`View cited chunk ${chunkId}`}
|
||||
>
|
||||
|
|
@ -88,36 +97,77 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
|||
};
|
||||
|
||||
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
|
||||
const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
|
||||
const docQuery = useSurfsenseDocPreviewQuery(chunkId, mobilePreviewOpen);
|
||||
|
||||
const handleMobileClick = () => {
|
||||
setMobilePreviewOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<CitationHoverPopover
|
||||
id={`doc-${chunkId}`}
|
||||
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
|
||||
align="start"
|
||||
trigger={(hoverProps) => (
|
||||
<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"
|
||||
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
|
||||
title="Surfsense documentation"
|
||||
{...hoverProps}
|
||||
<>
|
||||
<CitationHoverPopover
|
||||
id={`doc-${chunkId}`}
|
||||
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
|
||||
align="start"
|
||||
trigger={(hoverProps) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={null}
|
||||
onClick={isTouchLike ? handleMobileClick : undefined}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
|
||||
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
|
||||
title="Surfsense documentation"
|
||||
{...hoverProps}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
doc
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SurfsenseDocPreview chunkId={chunkId} />
|
||||
</CitationHoverPopover>
|
||||
<Drawer open={mobilePreviewOpen} onOpenChange={setMobilePreviewOpen} shouldScaleBackground={false}>
|
||||
<DrawerContent
|
||||
className="max-h-[85vh] z-80 bg-popover text-popover-foreground"
|
||||
overlayClassName="z-80"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
doc
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SurfsenseDocPreview chunkId={chunkId} />
|
||||
</CitationHoverPopover>
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="pb-0">
|
||||
<DrawerTitle>Surfsense documentation</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<SurfsenseDocPreviewContent chunkId={chunkId} query={docQuery} contentClassName="max-h-[60vh]" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
function useSurfsenseDocPreviewQuery(chunkId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
|
||||
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
type SurfsenseDocPreviewQuery = ReturnType<typeof useSurfsenseDocPreviewQuery>;
|
||||
|
||||
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const query = useSurfsenseDocPreviewQuery(chunkId);
|
||||
|
||||
return <SurfsenseDocPreviewContent chunkId={chunkId} query={query} />;
|
||||
};
|
||||
|
||||
const SurfsenseDocPreviewContent: FC<{
|
||||
chunkId: number;
|
||||
query: SurfsenseDocPreviewQuery;
|
||||
contentClassName?: string;
|
||||
}> = ({ chunkId, query, contentClassName = "max-h-72" }) => {
|
||||
const { data, isLoading, error } = query;
|
||||
|
||||
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
|
||||
|
||||
|
|
@ -142,7 +192,7 @@ const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
|
|||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto px-3 py-2 text-sm">
|
||||
<div className={`${contentClassName} overflow-auto px-3 py-2 text-sm`}>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<Spinner size="xs" />
|
||||
|
|
|
|||
|
|
@ -50,14 +50,6 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
|
|||
|
||||
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
|
||||
const startIndex = data?.chunk_start_index ?? 0;
|
||||
const citedIndexInWindow = data
|
||||
? Math.max(
|
||||
0,
|
||||
data.chunks.findIndex((c) => c.id === chunkId)
|
||||
)
|
||||
: 0;
|
||||
const shownAbove = citedIndexInWindow;
|
||||
const shownBelow = data ? Math.max(0, data.chunks.length - 1 - citedIndexInWindow) : 0;
|
||||
const hasMoreAbove = startIndex > 0;
|
||||
const hasMoreBelow = data ? startIndex + data.chunks.length < totalChunks : false;
|
||||
|
||||
|
|
@ -117,18 +109,16 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
|
|||
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 text-[11px] text-muted-foreground">
|
||||
<span>Chunk #{chunkId}</span>
|
||||
{totalChunks > 0 && <span>· {totalChunks} chunks</span>}
|
||||
<div className="flex items-center gap-1 shrink-0 text-[11px] text-muted-foreground">
|
||||
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 py-8 text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">Loading citation…</span>
|
||||
<div className="flex min-h-full items-center justify-center text-muted-foreground">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -163,14 +153,14 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
|
|||
<span
|
||||
className={
|
||||
isCited
|
||||
? "text-[11px] font-semibold text-primary"
|
||||
? "text-[11px] text-muted-foreground"
|
||||
: "text-[11px] font-medium text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`}
|
||||
Chunk #{chunk.id}
|
||||
</span>
|
||||
{isCited && (
|
||||
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span>
|
||||
<span className="text-[11px] font-semibold text-primary">Cited chunk</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
|
|
@ -191,10 +181,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
|
|||
</div>
|
||||
|
||||
{!isLoading && !error && data && (
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Showing {shownAbove} above · cited · {shownBelow} below
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-end gap-2 border-t px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{(hasMoreAbove || hasMoreBelow) && !expanded && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
|
||||
type PopoverContentProps = ComponentProps<typeof PopoverContent>;
|
||||
|
|
@ -113,6 +114,7 @@ export function CitationHoverPopover({
|
|||
sideOffset = 6,
|
||||
onContentClick,
|
||||
}: CitationHoverPopoverProps) {
|
||||
const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
|
||||
const { open, scheduleOpen, scheduleClose, handleOpenChange } = useCitationHoverState(id);
|
||||
const hoverProps = {
|
||||
onPointerEnter: scheduleOpen,
|
||||
|
|
@ -121,6 +123,10 @@ export function CitationHoverPopover({
|
|||
onBlur: scheduleClose,
|
||||
} satisfies CitationHoverTriggerProps;
|
||||
|
||||
if (isTouchLike) {
|
||||
return trigger({});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>{trigger(hoverProps)}</PopoverTrigger>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function Citation(props: CitationProps) {
|
|||
return (
|
||||
<CitationHoverPopover
|
||||
id={id}
|
||||
contentClassName="w-72 cursor-pointer p-0"
|
||||
contentClassName="w-72 cursor-pointer overflow-hidden p-0"
|
||||
onContentClick={handleClick}
|
||||
trigger={(hoverProps) => (
|
||||
<Button
|
||||
|
|
@ -107,20 +107,16 @@ export function Citation(props: CitationProps) {
|
|||
onClick={handleClick}
|
||||
{...hoverProps}
|
||||
className={cn(
|
||||
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
"ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
<span>{domain}</span>
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue