"use client"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, Copy, Download, FileText, Loader2, Maximize2, Minimize2, PanelLeftClose, PanelLeftOpen, } from "lucide-react"; import { apiUrl } from "@/lib/api"; import { docIconFor, formatBytes } from "@/lib/doc-attachments"; import { previewKindFor, resolveSourceUrl, type FilePreviewSource, } from "@/components/chat/preview/previewerFor"; // Full-size loading placeholder. Crucially, giving each dynamic() its own // `loading` keeps chunk-load suspension *inside* the preview pane. Without it, // the suspension bubbles up to the route-level wrapping the whole // page, so first-preview / file-type-switch flashed the entire page to a // spinner. const PreviewLoading = () => (
); // Reuse the chat renderers โ€” they're pure presentation given (url, filename) // and don't carry chat-specific state. next/dynamic's compiler requires the // options to be an inline object literal (not a shared const), so each call // repeats `{ loading, ssr }`. const PdfPreview = dynamic( () => import("@/components/chat/preview/previewers/PdfPreview"), { loading: PreviewLoading, ssr: false }, ); const ImagePreview = dynamic( () => import("@/components/chat/preview/previewers/ImagePreview"), { loading: PreviewLoading, ssr: false }, ); const SvgPreview = dynamic( () => import("@/components/chat/preview/previewers/SvgPreview"), { loading: PreviewLoading, ssr: false }, ); const MarkdownPreview = dynamic( () => import("@/components/chat/preview/previewers/MarkdownPreview"), { loading: PreviewLoading, ssr: false }, ); const TextPreview = dynamic( () => import("@/components/chat/preview/previewers/TextPreview"), { loading: PreviewLoading, ssr: false }, ); const DocxPreview = dynamic( () => import("@/components/chat/preview/previewers/DocxPreview"), { loading: PreviewLoading, ssr: false }, ); const XlsxPreview = dynamic( () => import("@/components/chat/preview/previewers/XlsxPreview"), { loading: PreviewLoading, ssr: false }, ); const OfficeTextPreview = dynamic( () => import("@/components/chat/preview/previewers/OfficeTextPreview"), { loading: PreviewLoading, ssr: false }, ); const FallbackPreview = dynamic( () => import("@/components/chat/preview/previewers/FallbackPreview"), { loading: PreviewLoading, ssr: false }, ); interface KbFilePreviewProps { source: FilePreviewSource | null; /** * Optional slot rendered to the right of the breadcrumb / title โ€” used * to surface metadata (e.g. file count, modified date) inline in the * preview header, since this component is the right pane of the * master-detail and there's no separate header above it. */ metaSuffix?: React.ReactNode; /** Current collapsed state of the file list. When provided, the preview * header shows a toggle so the user can claim more width without * reaching for the file list's own toggle button. */ fileListCollapsed?: boolean; onToggleFileList?: () => void; } /** * Inline file preview pane that fits the /knowledge master-detail layout. * * Unlike the chat's slide-in drawer, this is just an in-flow panel โ€” header * (filename + actions) on top, body (renderer) below. It owns no animation * timers and no portal; the parent decides how/when to mount it. That keeps * the visual language consistent with the rest of the knowledge surface * (tabs, sidebars, plain panels) instead of pulling in a drawer chrome that * fights the page's existing master-detail. */ export default function KbFilePreview({ source, metaSuffix, fileListCollapsed, onToggleFileList, }: KbFilePreviewProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); // Master-detail caps the inline pane height; fullscreen lets a tall PDF use // the whole window so you're not stuck reading half a page at a time. const [fullscreen, setFullscreen] = useState(false); useEffect(() => { if (!fullscreen) return; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setFullscreen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [fullscreen]); const previewUrl = useMemo( () => (source ? resolveSourceUrl(source, apiUrl) : null), [source], ); const extractedTextUrl = useMemo(() => { if (!source?.extractedTextUrl) return null; return source.extractedTextUrl.startsWith("http") ? source.extractedTextUrl : apiUrl(source.extractedTextUrl); }, [source]); const kind = useMemo( () => (source ? previewKindFor(source) : null), [source], ); if (!source) { return (
{onToggleFileList && (
)}
{t("Select a file to preview")}

{t( "Pick any document from the list on the left to view it here without leaving the knowledge base.", )}

); } const spec = docIconFor(source.filename); const HeaderIcon = spec.Icon; const sizeLabel = source.size ? formatBytes(source.size) : ""; const handleCopy = async () => { if (!previewUrl) return; try { await navigator.clipboard.writeText(previewUrl); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { // clipboard rejected; ignore } }; return (
{/* Header */}
{onToggleFileList && !fullscreen && ( )}
{source.filename}
{sizeLabel ? `${spec.label} ยท ${sizeLabel}` : spec.label}
{metaSuffix && (
{metaSuffix}
)} {previewUrl && ( <> )}
{/* Body */}
{!previewUrl ? ( ) : kind === "office-text" ? ( ) : kind === "pdf" ? ( ) : kind === "docx" ? ( ) : kind === "xlsx" ? ( ) : kind === "image" ? ( ) : kind === "svg" ? ( ) : kind === "markdown" ? (
) : kind === "code" || kind === "text" ? (
) : ( )}
); }