"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronDownIcon, Pencil, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
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 { MarkdownViewer } from "@/components/markdown-viewer";
import { EXPORT_FILE_EXTENSIONS, ExportDropdownItems } from "@/components/shared/ExportMenuItems";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
function ReportPanelSkeleton() {
return (
);
}
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => }
);
const PdfViewer = dynamic(
() => import("@/components/report-panel/pdf-viewer").then((m) => ({ default: m.PdfViewer })),
{ ssr: false, loading: () => }
);
/**
* 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(),
content_type: z.string().default("markdown"),
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;
/**
* 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);
const copyTimerRef = useRef | undefined>(undefined);
const changeCountRef = useRef(0);
useEffect(() => {
return () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
};
}, []);
// Editor state — tracks the latest markdown from the Plate editor
const [editedMarkdown, setEditedMarkdown] = useState(null);
const [isEditing, setIsEditing] = useState(false);
// 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);
setIsEditing(false);
changeCountRef.current = 0;
}, [activeReportId]);
const handleReportMarkdownChange = useCallback(
(nextMarkdown: string) => {
if (!isEditing) return;
changeCountRef.current += 1;
// Plate may emit an initial normalize/serialize change on mount.
if (changeCountRef.current <= 1) return;
const savedMarkdown = reportContent?.content ?? "";
setEditedMarkdown(nextMarkdown === savedMarkdown ? null : nextMarkdown);
},
[isEditing, reportContent?.content]
);
// Copy markdown content (uses latest editor content)
const handleCopy = useCallback(async () => {
if (!currentMarkdown) return;
try {
await navigator.clipboard.writeText(currentMarkdown);
setCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, [currentMarkdown]);
const handleExport = useCallback(
async (format: string) => {
setExporting(format);
const safeTitle =
title
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "report";
const ext = EXPORT_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 false;
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");
return true;
} catch (err) {
console.error("Error saving report:", err);
toast.error(err instanceof Error ? err.message : "Failed to save report");
return false;
} finally {
setSaving(false);
}
}, [activeReportId, currentMarkdown]);
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
const isPublic = !!shareToken;
const btnBg = isPublic ? "bg-main-panel" : "bg-sidebar";
const isResume = reportContent?.content_type === "typst";
const showReportEditingTier = !isResume;
const hasUnsavedChanges = editedMarkdown !== null;
const handleCancelEditing = useCallback(() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(false);
}, []);
return (
<>
{/* Action bar — always visible; buttons are disabled while loading */}
{/* Copy button — hidden for Typst (resume) */}
{reportContent?.content_type !== "typst" && (
{copied ? "Copied" : "Copy"}
)}
{/* Export — plain button for resume (typst), dropdown for others */}
{reportContent?.content_type === "typst" ? (
handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
>
{exporting === "pdf" ? : "Download"}
) : (
Export
)}
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 && (
v{activeVersionIndex + 1}
{versions.map((v, i) => (
setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
))}
)}
{onClose && (
Close report panel
)}
{showReportEditingTier && (
{reportContent?.title || title}
{!isReadOnly &&
(isEditing ? (
<>
Cancel
{
const saveSucceeded = await handleSave();
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
Save
{saving && }
>
) : (
{
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(true);
}}
>
Edit report
))}
)}
{/* 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_type === "typst" ? (
) : reportContent.content ? (
isReadOnly ? (
) : (
)
) : (
)}
>
);
}
/**
* 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;
const isPublic = !!panelState.shareToken;
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;
const isPublic = !!panelState.shareToken;
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 ;
}