"use client"; import { useAtomValue, useSetAtom } from "jotai"; import { ChevronDownIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { PlateEditor } from "@/components/editor/plate-editor"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; /** * Zod schema for a single version entry */ const VersionInfoSchema = z.object({ id: z.number(), created_at: z.string().nullish(), }); /** * 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(), report_group_id: z.number().nullish(), versions: z.array(VersionInfoSchema).nullish(), }); type ReportContentResponse = z.infer; type VersionInfo = 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 desktop panel, mobile drawer, and the layout right panel */ export function ReportPanelContent({ reportId, title, onClose, insideDrawer = false, shareToken, }: { reportId: number; title: string; onClose?: () => void; /** When true, adjusts dropdown behavior to work inside a Vaul drawer on mobile */ insideDrawer?: boolean; /** When set, uses public endpoint for fetching report data (public shared chat) */ shareToken?: string | null; }) { const [reportContent, setReportContent] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [exporting, setExporting] = useState(null); const [saving, setSaving] = useState(false); // Editor state — tracks the latest markdown from the Plate editor const [editedMarkdown, setEditedMarkdown] = useState(null); // Read-only when public (shareToken) OR shared (SEARCH_SPACE visibility) const currentThreadState = useAtomValue(currentThreadAtom); const isReadOnly = !!shareToken || currentThreadState.visibility === "SEARCH_SPACE"; // Version state const [activeReportId, setActiveReportId] = useState(reportId); const [versions, setVersions] = useState([]); // Reset active version when the external reportId changes (e.g. clicking a different card) useEffect(() => { setActiveReportId(reportId); }, [reportId]); // Fetch report content (re-runs when activeReportId changes for version switching) useEffect(() => { let cancelled = false; const fetchContent = async () => { setIsLoading(true); setError(null); try { const url = shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/content` : `/api/v1/reports/${activeReportId}/content`; const rawData = await baseApiService.get(url); 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); // Update versions from the response if (parsed.data.versions && parsed.data.versions.length > 0) { setVersions(parsed.data.versions); } } } 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; }; }, [activeReportId, shareToken]); // The current markdown: use edited version if available, otherwise original const currentMarkdown = editedMarkdown ?? reportContent?.content ?? null; // Reset edited markdown when switching versions or reports useEffect(() => { setEditedMarkdown(null); }, [activeReportId]); // Copy markdown content (uses latest editor content) const handleCopy = useCallback(async () => { if (!currentMarkdown) return; try { await navigator.clipboard.writeText(currentMarkdown); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { console.error("Failed to copy:", err); } }, [currentMarkdown]); // Maps backend format values to download file extensions const FILE_EXTENSIONS: Record = { pdf: "pdf", docx: "docx", html: "html", latex: "tex", epub: "epub", odt: "odt", plain: "txt", md: "md", }; // Export report const handleExport = useCallback( async (format: string) => { setExporting(format); const safeTitle = title .replace(/[^a-zA-Z0-9 _-]/g, "_") .trim() .slice(0, 80) || "report"; const ext = FILE_EXTENSIONS[format] ?? format; try { if (format === "md") { if (!currentMarkdown) return; const blob = new Blob([currentMarkdown], { type: "text/markdown;charset=utf-8", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${safeTitle}.${ext}`; 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/${activeReportId}/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}.${ext}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } } catch (err) { console.error(`Export ${format} failed:`, err); } finally { setExporting(null); } }, [activeReportId, title, currentMarkdown] ); // Save edited report content const handleSave = useCallback(async () => { if (!currentMarkdown || !activeReportId) return; setSaving(true); try { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: currentMarkdown }), } ); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: "Failed to save report" })); throw new Error(errorData.detail || "Failed to save report"); } // Update local state to reflect saved content setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev)); setEditedMarkdown(null); toast.success("Report saved successfully"); } catch (err) { console.error("Error saving report:", err); toast.error(err instanceof Error ? err.message : "Failed to save report"); } finally { setSaving(false); } }, [activeReportId, currentMarkdown]); const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId); return ( <> {/* Action bar — always visible; buttons are disabled while loading */}
{/* Copy button */} {/* Export dropdown */} {!shareToken && ( <> Documents handleExport("pdf")} disabled={exporting !== null} > PDF (.pdf) handleExport("docx")} disabled={exporting !== null} > Word (.docx) handleExport("odt")} disabled={exporting !== null} > OpenDocument (.odt) Web & E-Book handleExport("html")} disabled={exporting !== null} > HTML (.html) handleExport("epub")} disabled={exporting !== null} > EPUB (.epub) Source & Plain handleExport("latex")} disabled={exporting !== null} > LaTeX (.tex) )} handleExport("md")} disabled={exporting !== null}> Markdown (.md) {!shareToken && ( handleExport("plain")} disabled={exporting !== null} > Plain Text (.txt) )} {/* Version switcher — only shown when multiple versions exist */} {versions.length > 1 && ( {versions.map((v, i) => ( setActiveReportId(v.id)} className={v.id === activeReportId ? "bg-accent font-medium" : ""} > Version {i + 1} ))} )}
{onClose && ( )}
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
{isLoading ? ( ) : error || !reportContent ? (

Failed to load report

{error || "An unknown error occurred"}

) : reportContent.content ? ( isReadOnly ? (
) : ( ) ) : (

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} > {panelState.title || "Report"}
); } /** * 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 */ 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 ; } /** * MobileReportPanel — mobile-only report drawer * * Used in the dashboard chat page where the desktop report is handled * by the layout-level RightPanel instead. */ export function MobileReportPanel() { const panelState = useAtomValue(reportPanelAtom); const isDesktop = useMediaQuery("(min-width: 1024px)"); if (isDesktop || !panelState.isOpen || !panelState.reportId) return null; return ; }