feat: enhance UI components and functionality across documents and report panels

- Updated styling for toggle buttons in DocumentsFilters for improved visual feedback.
- Added toolbar actions prop to PdfViewer for customizable actions in the zoom toolbar.
- Introduced download functionality in ReportPanel with a new Download icon.
- Refactored ReportCard to remove word count and display version labels for better clarity.
- Enhanced GenerateResume component to fetch and display version information for resumes.
This commit is contained in:
Anish Sarkar 2026-04-24 22:45:33 +05:30
parent 9b1b9a90c0
commit 08740cad32
5 changed files with 264 additions and 185 deletions

View file

@ -84,7 +84,7 @@ export function DocumentsFilters({
<TooltipTrigger asChild>
<ToggleGroupItem
value="folder"
className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar"
className="h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
onClick={(e) => {
e.preventDefault();
onCreateFolder();
@ -104,11 +104,11 @@ export function DocumentsFilters({
value="ai-sort"
disabled={aiSortBusy}
className={cn(
"h-9 w-9 shrink-0 border-sidebar-border bg-sidebar",
"h-9 w-9 shrink-0 border bg-muted/50 transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
aiSortEnabled
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:border-sidebar-border"
? "bg-accent text-accent-foreground hover:bg-accent"
: "text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
onClick={(e) => {
e.preventDefault();
@ -142,11 +142,11 @@ export function DocumentsFilters({
<PopoverTrigger asChild>
<ToggleGroupItem
value="filter"
className="relative h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar overflow-visible"
className="relative h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground overflow-visible"
>
<ListFilter size={14} />
{activeTypes.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar-border text-[9px] font-medium text-sidebar-foreground">
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-neutral-300 text-[9px] font-medium text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200">
{activeTypes.length}
</span>
)}

View file

@ -3,7 +3,7 @@
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import * as pdfjsLib from "pdfjs-dist";
import { useCallback, useEffect, useRef, useState } from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getAuthHeaders } from "@/lib/auth-utils";
@ -16,6 +16,8 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
interface PdfViewerProps {
pdfUrl: string;
isPublic?: boolean;
/** Extra actions rendered on the right side of the zoom toolbar (e.g. download, version switcher) */
toolbarActions?: ReactNode;
}
interface PageDimensions {
@ -30,7 +32,7 @@ const PAGE_GAP = 12;
const SCROLL_DEBOUNCE_MS = 30;
const BUFFER_PAGES = 1;
export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
export function PdfViewer({ pdfUrl, isPublic = false, toolbarActions }: PdfViewerProps) {
const [numPages, setNumPages] = useState(0);
const [scale, setScale] = useState(1);
const [loading, setLoading] = useState(true);
@ -286,29 +288,33 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
<div className="flex flex-col h-full">
{numPages > 0 && (
<div
className={`flex items-center justify-center gap-2 px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
className={`flex items-center px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
>
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
disabled={scale <= MIN_ZOOM}
className="size-7"
>
<ZoomOutIcon className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
{Math.round(scale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
disabled={scale >= MAX_ZOOM}
className="size-7"
>
<ZoomInIcon className="size-4" />
</Button>
<div className="flex-1" aria-hidden="true" />
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
disabled={scale <= MIN_ZOOM}
className="size-7"
>
<ZoomOutIcon className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
{Math.round(scale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
disabled={scale >= MAX_ZOOM}
className="size-7"
>
<ZoomInIcon className="size-4" />
</Button>
</div>
<div className="flex flex-1 items-center justify-end gap-1">{toolbarActions}</div>
</div>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Check, ChevronDownIcon, Copy, Pencil, XIcon } from "lucide-react";
import { Check, ChevronDownIcon, Copy, Download, Pencil, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@ -309,6 +309,7 @@ export function ReportPanelContent({
const isResume = reportContent?.content_type === "typst";
const showReportEditingTier = !isResume;
const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose;
const handleCancelEditing = useCallback(() => {
setEditedMarkdown(null);
@ -316,153 +317,177 @@ export function ReportPanelContent({
setIsEditing(false);
}, []);
const exportButton = !isEditing && (
<>
{isResume ? (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
>
{exporting === "pdf" ? <Spinner size="xs" /> : <Download className="size-3.5" />}
<span className="sr-only">Download report</span>
</Button>
) : (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={isLoading || !reportContent?.content}
>
<Download className="size-3.5" />
<span className="sr-only">Export report</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
);
const versionSwitcher = !isEditing && versions.length > 1 && (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-1.5 text-xs">
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const copyButton = !isEditing && showReportEditingTier && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
void handleCopy();
}}
disabled={isLoading || !reportContent?.content}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">{copied ? "Copied report content" : "Copy report content"}</span>
</Button>
);
const editingActions = showReportEditingTier &&
!isReadOnly &&
(isEditing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleCancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
className="relative h-6 w-[56px] px-0 text-xs"
onClick={async () => {
const saveSucceeded = await handleSave();
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
<span className={saving ? "opacity-0" : ""}>Save</span>
{saving && <Spinner size="xs" className="absolute" />}
</Button>
</>
) : (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit report</span>
</Button>
));
return (
<>
{/* Action bar — always visible; buttons are disabled while loading */}
<div className="flex h-14 items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
{/* Export — plain button for resume (typst), dropdown for others */}
{reportContent?.content_type === "typst" ? (
<Button
variant="outline"
size="sm"
onClick={() => handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
{exporting === "pdf" ? <Spinner size="xs" /> : "Download"}
</Button>
) : (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
Export
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 && (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
{showReportEditingTier && (
<div className="flex h-10 items-center justify-between gap-2 border-t border-b px-4 shrink-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">
{reportContent?.title || title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{!isEditing && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
void handleCopy();
}}
disabled={isLoading || !reportContent?.content}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">
{copied ? "Copied report content" : "Copy report content"}
</span>
{showDesktopHeader ? (
<>
{/* Header — matches the editor panel "File" header pattern */}
<div className="flex h-14 items-center justify-between px-4 shrink-0">
<h2 className="text-lg font-medium text-muted-foreground select-none">
{isResume ? "Resume" : "Report"}
</h2>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
{!isReadOnly &&
(isEditing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleCancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
className="relative h-6 w-[56px] px-0 text-xs"
onClick={async () => {
const saveSucceeded = await handleSave();
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
<span className={saving ? "opacity-0" : ""}>Save</span>
{saving && <Spinner size="xs" className="absolute" />}
</Button>
</>
) : (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit report</span>
</Button>
))}
</div>
</div>
{!isResume && (
<div className="flex h-10 items-center justify-between gap-2 border-t border-b px-4 shrink-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">
{reportContent?.title || title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{versionSwitcher}
{exportButton}
{copyButton}
{editingActions}
</div>
</div>
)}
</>
) : (
!isResume && (
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{reportContent?.title || title}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{versionSwitcher}
{exportButton}
{copyButton}
{editingActions}
</div>
</div>
)
)}
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
@ -480,6 +505,12 @@ export function ReportPanelContent({
<PdfViewer
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
isPublic={isPublic}
toolbarActions={
<>
{versionSwitcher}
{exportButton}
</>
}
/>
) : reportContent.content ? (
isReadOnly ? (

View file

@ -137,10 +137,9 @@ function ReportCard({
const autoOpenedRef = useRef(false);
const [metadata, setMetadata] = useState<{
title: string;
wordCount: number | null;
versionLabel: string | null;
content: string | null;
}>({ title, wordCount: wordCount ?? null, versionLabel: null, content: null });
}>({ title, versionLabel: null, content: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -169,10 +168,8 @@ function ReportCard({
}
}
const resolvedTitle = parsed.data.title || title;
const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
setMetadata({
title: resolvedTitle,
wordCount: resolvedWordCount,
versionLabel,
content: parsed.data.content ?? null,
});
@ -182,7 +179,7 @@ function ReportCard({
openPanel({
reportId,
title: resolvedTitle,
wordCount: resolvedWordCount ?? undefined,
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? undefined,
shareToken,
});
}
@ -210,7 +207,6 @@ function ReportCard({
openPanel({
reportId,
title: metadata.title,
wordCount: metadata.wordCount ?? undefined,
shareToken,
});
};
@ -233,10 +229,8 @@ function ReportCard({
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
) : (
<>
{metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`}
{metadata.wordCount != null && metadata.versionLabel && (
<Dot className="inline size-4" />
)}
Markdown
{metadata.versionLabel && <Dot className="inline size-4" />}
{metadata.versionLabel}
</>
)}

View file

@ -2,6 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Dot } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import * as pdfjsLib from "pdfjs-dist";
import { useCallback, useEffect, useRef, useState } from "react";
@ -9,6 +10,7 @@ import { z } from "zod";
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { getAuthHeaders } from "@/lib/auth-utils";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
@ -32,6 +34,18 @@ const GenerateResumeResultSchema = z.object({
error: z.string().nullish(),
});
const ResumeVersionsResponseSchema = z.object({
id: z.number(),
versions: z
.array(
z.object({
id: z.number(),
created_at: z.string().nullish(),
})
)
.nullish(),
});
type GenerateResumeArgs = z.infer<typeof GenerateResumeArgsSchema>;
type GenerateResumeResult = z.infer<typeof GenerateResumeResultSchema>;
@ -201,6 +215,7 @@ function ResumeCard({
const autoOpenedRef = useRef(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading");
const [versionLabel, setVersionLabel] = useState<string | null>(null);
useEffect(() => {
const previewPath = shareToken
@ -219,6 +234,35 @@ function ResumeCard({
}
}, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]);
useEffect(() => {
let cancelled = false;
const fetchVersions = async () => {
try {
const url = shareToken
? `/api/v1/public/${shareToken}/reports/${reportId}/content`
: `/api/v1/reports/${reportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return;
const parsed = ResumeVersionsResponseSchema.safeParse(rawData);
if (parsed.success) {
const versions = parsed.data.versions;
if (versions && versions.length > 1) {
const idx = versions.findIndex((v) => v.id === reportId);
if (idx >= 0) {
setVersionLabel(`version ${idx + 1}`);
}
}
}
} catch {
// silently ignore — version label is non-critical
}
};
fetchVersions();
return () => {
cancelled = true;
};
}, [reportId, shareToken]);
const onThumbLoad = useCallback(() => setThumbState("ready"), []);
const onThumbError = useCallback(() => setThumbState("error"), []);
@ -243,8 +287,12 @@ function ResumeCard({
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer select-none"
>
<div className="px-5 pt-5 pb-4">
<p className="text-base font-semibold text-foreground line-clamp-2">{title}</p>
<p className="text-sm text-muted-foreground mt-0.5">PDF</p>
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
PDF
{versionLabel && <Dot className="inline size-4" />}
{versionLabel}
</p>
</div>
<div className="mx-5 h-px bg-border/50" />