"use client"; import * as React from "react"; import { useRouter } from "next/navigation"; import { Braces, Download, Eye, FileCode2, FileSpreadsheet, FileText, FolderInput, FolderKanban, Image as ImageIcon, MessageSquarePlus, Pencil, Trash2 } from "lucide-react"; import { toast } from "@/components/ui/toast"; import { Skeleton, EmptyState } from "@/components/ui/misc"; import { ResponsiveDialog } from "@/components/ui/sheet"; import { ConfirmDialog } from "@/components/common/confirm-dialog"; import { PromptDialog } from "@/components/common/prompt-dialog"; import { RowMenu } from "@/components/projects/row-menu"; import { ProjectIcon } from "@/components/projects/project-icon"; import { api } from "@/lib/client/api"; import { errorMessage } from "@/lib/client/humanize"; import { useIsMobile, useLongPress } from "@/lib/client/hooks"; import { formatRelative, formatTokens, cn } from "@/lib/utils"; import type { PublicProjectFile, PublicProject } from "@/lib/client/types"; import type { ActionSheetItem } from "@/components/ui/sheet"; import { formatBytes, kindLabel } from "./format"; import { prepareFilesForNewChat } from "./use-in-chat"; export function FileKindIcon({ kind, className }: { kind: string; className?: string }) { const c = cn("size-4", className); switch (kind) { case "image": return ; case "csv": return ; case "json": return ; case "code": return ; default: return ; } } /** 40 px thumbnail: real image preview for images, kind glyph otherwise. */ export function FileThumb({ file, size = "md" }: { file: PublicProjectFile; size?: "sm" | "md" }) { const dim = size === "sm" ? "size-8 rounded-md" : "size-10 rounded-lg"; if (file.kind === "image") { // eslint-disable-next-line @next/next/no-img-element return ; } return ( ); } export interface FileListProps { files: PublicProjectFile[] | undefined; loading?: boolean; /** Known projects, for the project label and the "Move to…" actions. */ projects?: PublicProject[]; /** Show the project chip on each row (global library view). */ showProject?: boolean; onChanged?: () => void | Promise; /** When set, "Use in new chat" also activates this project. */ contextProjectId?: string | null; emptyTitle?: string; emptyDescription?: string; emptyAction?: React.ReactNode; className?: string; } /** Stacked rows (phone) / dense rows (desktop) with per-file actions. */ export function FileList({ files, loading, projects = [], showProject, onChanged, contextProjectId, emptyTitle = "No files yet", emptyDescription = "Upload PDFs, images, code or data once and reuse them in any chat.", emptyAction, className }: FileListProps) { const router = useRouter(); const [preview, setPreview] = React.useState(null); const [editing, setEditing] = React.useState(null); const [deleting, setDeleting] = React.useState(null); const [moving, setMoving] = React.useState(null); const projectById = React.useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects]); const attachToNewChat = async (f: PublicProjectFile) => { try { const { href } = await prepareFilesForNewChat([f.id]); router.push(contextProjectId ? `${href}&project=${encodeURIComponent(contextProjectId)}` : href); } catch (e) { toast.error("Could not attach file", errorMessage(e)); } }; const download = (f: PublicProjectFile) => { const a = document.createElement("a"); a.href = `/api/library/files/${f.id}?download=1`; a.download = f.name; a.click(); }; const move = async (f: PublicProjectFile, projectId: string | null) => { try { await api(`/api/library/files/${f.id}`, { method: "PATCH", json: { projectId } }); await onChanged?.(); toast.success(projectId ? `Moved to ${projectById.get(projectId)?.name ?? "project"}` : "Moved to the global library"); } catch (e) { toast.error("Could not move file", errorMessage(e)); } }; const remove = async (f: PublicProjectFile) => { try { await api(`/api/library/files/${f.id}`, { method: "DELETE" }); await onChanged?.(); toast.success("File deleted"); } catch (e) { toast.error("Could not delete file", errorMessage(e)); throw e; } }; const itemsFor = (f: PublicProjectFile): (ActionSheetItem | "separator")[] => [ { key: "use", label: "Use in new chat", icon: , onSelect: () => void attachToNewChat(f) }, { key: "preview", label: f.kind === "pdf" ? "Open" : "Preview", icon: , onSelect: () => (f.kind === "pdf" ? window.open(`/api/library/files/${f.id}`, "_blank", "noopener") : setPreview(f)) }, { key: "download", label: "Download", icon: , onSelect: () => download(f) }, "separator", { key: "move", label: f.projectId ? "Move to another project…" : "Move to a project…", icon: , onSelect: () => setMoving(f), disabled: projects.length === 0 && !f.projectId }, { key: "edit", label: "Edit description", icon: , onSelect: () => setEditing(f) }, "separator", { key: "delete", label: "Delete", icon: , destructive: true, onSelect: () => setDeleting(f) }, ]; if (loading && !files) { return (
{Array.from({ length: 5 }).map((_, i) => ( ))}
); } if (!files?.length) return } title={emptyTitle} description={emptyDescription} action={emptyAction} className={className} />; return ( <>
    {files.map((f) => ( (f.kind === "pdf" ? window.open(`/api/library/files/${f.id}`, "_blank", "noopener") : setPreview(f))} /> ))}
!o && setPreview(null)} onUse={attachToNewChat} onDownload={download} /> !o && setEditing(null)} title="Description" description="Shown in the library and used as context when the file is injected into a prompt." defaultValue={editing?.description ?? ""} multiline maxLength={500} placeholder="Q3 pricing sheet, includes EU margins…" onSubmit={async (v) => { if (!editing) return; await api(`/api/library/files/${editing.id}`, { method: "PATCH", json: { description: v } }); await onChanged?.(); }} /> !o && setMoving(null)} title="Move file" description={moving?.name} size="sm">
  • {projects .filter((p) => !p.archived) .map((p) => (
  • ))}
!o && setDeleting(null)} destructive title={`Delete “${deleting?.name ?? ""}”?`} description="Messages that already used this file keep their own copy." confirmLabel="Delete" onConfirm={() => (deleting ? remove(deleting) : Promise.resolve())} /> ); } function FileRow({ file, project, items, onOpen }: { file: PublicProjectFile; project?: PublicProject; items: (ActionSheetItem | "separator")[]; onOpen: () => void }) { const isMobile = useIsMobile(); const [menu, setMenu] = React.useState(false); const press = useLongPress({ onLongPress: () => setMenu(true), disabled: !isMobile }); return (
  • ); } /** Image / text preview in a sheet (PDFs open in a new tab). */ function FilePreviewSheet({ file, onOpenChange, onUse, onDownload }: { file: PublicProjectFile | null; onOpenChange: (o: boolean) => void; onUse: (f: PublicProjectFile) => void; onDownload: (f: PublicProjectFile) => void }) { const [text, setText] = React.useState(null); const isText = file && file.kind !== "image" && file.kind !== "pdf"; React.useEffect(() => { if (!file || !isText) return; let cancelled = false; fetch(`/api/library/files/${file.id}`, { credentials: "same-origin" }) .then((r) => r.text()) .then((t) => { if (!cancelled) setText(t.length > 40_000 ? `${t.slice(0, 40_000)}\n… (${formatBytes(file.sizeBytes)} total)` : t); }) .catch(() => { if (!cancelled) setText("Could not load the file."); }); return () => { cancelled = true; setText(null); }; }, [file, isText]); return ( ) : undefined } > {file?.kind === "image" ? ( // eslint-disable-next-line @next/next/no-img-element {file.name} ) : isText ? ( text === null ? ( ) : (
    {text}
    ) ) : null}
    ); }