"use client"; import { useAtomValue, useSetAtom } from "jotai"; import { CheckIcon, ChevronDownIcon, ClipboardIcon, DownloadIcon, FileTextIcon, Loader2Icon, XIcon, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { closeReportPanelAtom, reportPanelAtom, } from "@/atoms/chat/report-panel.atom"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; /** * Zod schema for the report content API response */ const ReportContentResponseSchema = z.object({ id: z.number(), title: z.string(), content: z.string().nullish(), report_metadata: z .object({ status: z.enum(["ready", "failed"]).nullish(), error_message: z.string().nullish(), word_count: z.number().nullish(), char_count: z.number().nullish(), section_count: z.number().nullish(), }) .nullish(), }); type ReportContentResponse = z.infer; /** * Shimmer loading skeleton for report panel */ function ReportPanelSkeleton() { return (
{/* Title skeleton */}
{/* Paragraph 1 */}
{/* Heading */}
{/* Paragraph 2 */}
{/* Heading */}
{/* Paragraph 3 */}
); } /** * Inner content component used by both desktop panel and mobile drawer */ function ReportPanelContent({ reportId, title, onClose, }: { reportId: number; title: string; onClose?: () => void; }) { const [reportContent, setReportContent] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null); // Fetch report content useEffect(() => { let cancelled = false; const fetchContent = async () => { setIsLoading(true); setError(null); try { const rawData = await baseApiService.get( `/api/v1/reports/${reportId}/content` ); if (cancelled) return; const parsed = ReportContentResponseSchema.safeParse(rawData); if (parsed.success) { // Check if the report was marked as failed in metadata if (parsed.data.report_metadata?.status === "failed") { setError( parsed.data.report_metadata?.error_message || "Report generation failed" ); } else { setReportContent(parsed.data); } } else { console.warn( "Invalid report content response:", parsed.error.issues ); setError("Invalid response format"); } } catch (err) { if (cancelled) return; console.error("Error fetching report content:", err); setError( err instanceof Error ? err.message : "Failed to load report" ); } finally { if (!cancelled) setIsLoading(false); } }; fetchContent(); return () => { cancelled = true; }; }, [reportId]); // Copy markdown content const handleCopy = useCallback(async () => { if (!reportContent?.content) return; try { await navigator.clipboard.writeText(reportContent.content); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { console.error("Failed to copy:", err); } }, [reportContent?.content]); // Export report const handleExport = useCallback( async (format: "pdf" | "docx" | "md") => { setExporting(format); const safeTitle = title.replace(/[^a-zA-Z0-9 _-]/g, "_").trim().slice(0, 80) || "report"; try { if (format === "md") { // Download markdown content directly as a .md file if (!reportContent?.content) return; const blob = new Blob([reportContent.content], { type: "text/markdown;charset=utf-8", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${safeTitle}.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } else { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/export?format=${format}`, { method: "GET" } ); if (!response.ok) { throw new Error(`Export failed: ${response.status}`); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${safeTitle}.${format}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } } catch (err) { console.error(`Export ${format} failed:`, err); } finally { setExporting(null); } }, [reportId, title, reportContent?.content] ); if (isLoading) { return ; } if (error || !reportContent) { return (

Failed to load report

{error || "An unknown error occurred"}

); } return ( <> {/* Action bar */}
handleExport("md")}> Download Markdown handleExport("pdf")} disabled={exporting !== null} > {exporting === "pdf" ? ( ) : ( )} Download PDF handleExport("docx")} disabled={exporting !== null} > {exporting === "docx" ? ( ) : ( )} Download DOCX
{onClose && ( )}
{/* Report content */}
{reportContent.content ? ( ) : (

No content available.

)}
); } /** * Desktop report panel — renders as a right-side flex sibling */ function DesktopReportPanel() { const panelState = useAtomValue(reportPanelAtom); const closePanel = useSetAtom(closeReportPanelAtom); const panelRef = useRef(null); // Close panel on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { closePanel(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [closePanel]); if (!panelState.isOpen || !panelState.reportId) return null; return (
); } /** * Mobile report drawer — uses Vaul (same pattern as comment sheet) */ function MobileReportDrawer() { const panelState = useAtomValue(reportPanelAtom); const closePanel = useSetAtom(closeReportPanelAtom); if (!panelState.reportId) return null; return ( { if (!open) closePanel(); }} shouldScaleBackground={false} >
); } /** * ReportPanel — responsive report viewer * * On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread) * On mobile/tablet: Renders as a Vaul bottom drawer * * When open on desktop, the comments gutter is automatically suppressed * (handled via showCommentsGutterAtom in current-thread.atom.ts) */ export function ReportPanel() { const panelState = useAtomValue(reportPanelAtom); const isDesktop = useMediaQuery("(min-width: 1024px)"); // Don't render anything if panel is not open if (!panelState.isOpen || !panelState.reportId) return null; if (isDesktop) { return ; } return ; }