TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { useRouter } from "next/navigation";4import { Braces, Download, Eye, FileCode2, FileSpreadsheet, FileText, FolderInput, FolderKanban, Image as ImageIcon, MessageSquarePlus, Pencil, Trash2 } from "lucide-react";5import { toast } from "@/components/ui/toast";6import { Skeleton, EmptyState } from "@/components/ui/misc";7import { ResponsiveDialog } from "@/components/ui/sheet";8import { ConfirmDialog } from "@/components/common/confirm-dialog";9import { PromptDialog } from "@/components/common/prompt-dialog";10import { RowMenu } from "@/components/projects/row-menu";11import { ProjectIcon } from "@/components/projects/project-icon";12import { api } from "@/lib/client/api";13import { errorMessage } from "@/lib/client/humanize";14import { useIsMobile, useLongPress } from "@/lib/client/hooks";15import { formatRelative, formatTokens, cn } from "@/lib/utils";16import type { PublicProjectFile, PublicProject } from "@/lib/client/types";17import type { ActionSheetItem } from "@/components/ui/sheet";18import { formatBytes, kindLabel } from "./format";19import { prepareFilesForNewChat } from "./use-in-chat";2021export function FileKindIcon({ kind, className }: { kind: string; className?: string }) {22 const c = cn("size-4", className);23 switch (kind) {24 case "image":25 return <ImageIcon className={c} />;26 case "csv":27 return <FileSpreadsheet className={c} />;28 case "json":29 return <Braces className={c} />;30 case "code":31 return <FileCode2 className={c} />;32 default:33 return <FileText className={c} />;34 }35}3637/** 40 px thumbnail: real image preview for images, kind glyph otherwise. */38export function FileThumb({ file, size = "md" }: { file: PublicProjectFile; size?: "sm" | "md" }) {39 const dim = size === "sm" ? "size-8 rounded-md" : "size-10 rounded-lg";40 if (file.kind === "image") {41 // eslint-disable-next-line @next/next/no-img-element42 return <img src={`/api/library/files/${file.id}`} alt="" className={cn("shrink-0 object-cover bg-bg-muted", dim)} loading="lazy" />;43 }44 return (45 <span className={cn("flex shrink-0 items-center justify-center bg-bg-muted text-fg-muted", dim)} aria-hidden>46 <FileKindIcon kind={file.kind} />47 </span>48 );49}5051export interface FileListProps {52 files: PublicProjectFile[] | undefined;53 loading?: boolean;54 /** Known projects, for the project label and the "Move to…" actions. */55 projects?: PublicProject[];56 /** Show the project chip on each row (global library view). */57 showProject?: boolean;58 onChanged?: () => void | Promise<unknown>;59 /** When set, "Use in new chat" also activates this project. */60 contextProjectId?: string | null;61 emptyTitle?: string;62 emptyDescription?: string;63 emptyAction?: React.ReactNode;64 className?: string;65}6667/** Stacked rows (phone) / dense rows (desktop) with per-file actions. */68export 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) {69 const router = useRouter();70 const [preview, setPreview] = React.useState<PublicProjectFile | null>(null);71 const [editing, setEditing] = React.useState<PublicProjectFile | null>(null);72 const [deleting, setDeleting] = React.useState<PublicProjectFile | null>(null);73 const [moving, setMoving] = React.useState<PublicProjectFile | null>(null);74 const projectById = React.useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects]);7576 const attachToNewChat = async (f: PublicProjectFile) => {77 try {78 const { href } = await prepareFilesForNewChat([f.id]);79 router.push(contextProjectId ? `${href}&project=${encodeURIComponent(contextProjectId)}` : href);80 } catch (e) {81 toast.error("Could not attach file", errorMessage(e));82 }83 };84 const download = (f: PublicProjectFile) => {85 const a = document.createElement("a");86 a.href = `/api/library/files/${f.id}?download=1`;87 a.download = f.name;88 a.click();89 };90 const move = async (f: PublicProjectFile, projectId: string | null) => {91 try {92 await api(`/api/library/files/${f.id}`, { method: "PATCH", json: { projectId } });93 await onChanged?.();94 toast.success(projectId ? `Moved to ${projectById.get(projectId)?.name ?? "project"}` : "Moved to the global library");95 } catch (e) {96 toast.error("Could not move file", errorMessage(e));97 }98 };99 const remove = async (f: PublicProjectFile) => {100 try {101 await api(`/api/library/files/${f.id}`, { method: "DELETE" });102 await onChanged?.();103 toast.success("File deleted");104 } catch (e) {105 toast.error("Could not delete file", errorMessage(e));106 throw e;107 }108 };109110 const itemsFor = (f: PublicProjectFile): (ActionSheetItem | "separator")[] => [111 { key: "use", label: "Use in new chat", icon: <MessageSquarePlus />, onSelect: () => void attachToNewChat(f) },112 { key: "preview", label: f.kind === "pdf" ? "Open" : "Preview", icon: <Eye />, onSelect: () => (f.kind === "pdf" ? window.open(`/api/library/files/${f.id}`, "_blank", "noopener") : setPreview(f)) },113 { key: "download", label: "Download", icon: <Download />, onSelect: () => download(f) },114 "separator",115 { key: "move", label: f.projectId ? "Move to another project…" : "Move to a project…", icon: <FolderInput />, onSelect: () => setMoving(f), disabled: projects.length === 0 && !f.projectId },116 { key: "edit", label: "Edit description", icon: <Pencil />, onSelect: () => setEditing(f) },117 "separator",118 { key: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => setDeleting(f) },119 ];120121 if (loading && !files) {122 return (123 <div className={cn("space-y-2", className)}>124 {Array.from({ length: 5 }).map((_, i) => (125 <Skeleton key={i} className="h-14" />126 ))}127 </div>128 );129 }130 if (!files?.length) return <EmptyState icon={<FileText />} title={emptyTitle} description={emptyDescription} action={emptyAction} className={className} />;131132 return (133 <>134 <ul className={cn("divide-y divide-hairline rounded-xl border border-border bg-bg-elevated", className)}>135 {files.map((f) => (136 <FileRow key={f.id} file={f} project={showProject && f.projectId ? projectById.get(f.projectId) : undefined} items={itemsFor(f)} onOpen={() => (f.kind === "pdf" ? window.open(`/api/library/files/${f.id}`, "_blank", "noopener") : setPreview(f))} />137 ))}138 </ul>139140 <FilePreviewSheet file={preview} onOpenChange={(o) => !o && setPreview(null)} onUse={attachToNewChat} onDownload={download} />141142 <PromptDialog143 open={editing !== null}144 onOpenChange={(o) => !o && setEditing(null)}145 title="Description"146 description="Shown in the library and used as context when the file is injected into a prompt."147 defaultValue={editing?.description ?? ""}148 multiline149 maxLength={500}150 placeholder="Q3 pricing sheet, includes EU margins…"151 onSubmit={async (v) => {152 if (!editing) return;153 await api(`/api/library/files/${editing.id}`, { method: "PATCH", json: { description: v } });154 await onChanged?.();155 }}156 />157158 <ResponsiveDialog open={moving !== null} onOpenChange={(o) => !o && setMoving(null)} title="Move file" description={moving?.name} size="sm">159 <ul className="divide-y divide-hairline pt-1">160 <li>161 <button type="button" className="flex min-h-[48px] w-full items-center gap-3 px-1 text-left text-[15px] sm:text-sm" disabled={!moving?.projectId} onClick={() => moving && (setMoving(null), void move(moving, null))}>162 <span className="flex size-8 items-center justify-center rounded-md bg-bg-muted text-fg-muted">163 <FolderKanban className="size-4" />164 </span>165 <span className="flex-1">Global library</span>166 {!moving?.projectId ? <span className="text-[12px] text-fg-subtle">current</span> : null}167 </button>168 </li>169 {projects170 .filter((p) => !p.archived)171 .map((p) => (172 <li key={p.id}>173 <button type="button" className="flex min-h-[48px] w-full items-center gap-3 px-1 text-left text-[15px] sm:text-sm disabled:opacity-50" disabled={moving?.projectId === p.id} onClick={() => moving && (setMoving(null), void move(moving, p.id))}>174 <ProjectIcon icon={p.icon} color={p.color} size="sm" className="size-8" />175 <span className="min-w-0 flex-1 truncate">{p.name}</span>176 {moving?.projectId === p.id ? <span className="text-[12px] text-fg-subtle">current</span> : null}177 </button>178 </li>179 ))}180 </ul>181 </ResponsiveDialog>182183 <ConfirmDialog open={deleting !== null} onOpenChange={(o) => !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())} />184 </>185 );186}187188function FileRow({ file, project, items, onOpen }: { file: PublicProjectFile; project?: PublicProject; items: (ActionSheetItem | "separator")[]; onOpen: () => void }) {189 const isMobile = useIsMobile();190 const [menu, setMenu] = React.useState(false);191 const press = useLongPress({ onLongPress: () => setMenu(true), disabled: !isMobile });192 return (193 <li className="group flex min-h-[56px] items-center gap-3 px-3 py-2 sm:min-h-[52px]" {...press}>194 <button type="button" onClick={onOpen} className="flex min-w-0 flex-1 items-center gap-3 text-left" aria-label={`Preview ${file.name}`}>195 <FileThumb file={file} />196 <span className="min-w-0 flex-1">197 <span className="flex items-center gap-2">198 <span className="truncate text-[14px] font-medium leading-5">{file.name}</span>199 {project ? (200 <span className="hidden items-center gap-1 rounded-full bg-bg-muted px-1.5 py-0.5 text-[10.5px] text-fg-muted sm:inline-flex">201 <span aria-hidden>{project.icon ?? "◆"}</span> {project.name}202 </span>203 ) : null}204 </span>205 <span className="block truncate text-[12px] leading-4 text-fg-subtle tabular-nums">206 {kindLabel(file.kind)} · {formatBytes(file.sizeBytes)}207 {file.estimatedTokens ? ` · ~${formatTokens(file.estimatedTokens)} tokens` : ""} · {formatRelative(file.createdAt)}208 {project ? <span className="sm:hidden"> · {project.name}</span> : null}209 </span>210 {file.description ? <span className="mt-0.5 block truncate text-[12px] leading-4 text-fg-muted">{file.description}</span> : null}211 </span>212 </button>213 <RowMenu items={items} title={file.name} open={menu} onOpenChange={setMenu} />214 </li>215 );216}217218/** Image / text preview in a sheet (PDFs open in a new tab). */219function FilePreviewSheet({ file, onOpenChange, onUse, onDownload }: { file: PublicProjectFile | null; onOpenChange: (o: boolean) => void; onUse: (f: PublicProjectFile) => void; onDownload: (f: PublicProjectFile) => void }) {220 const [text, setText] = React.useState<string | null>(null);221 const isText = file && file.kind !== "image" && file.kind !== "pdf";222 React.useEffect(() => {223 if (!file || !isText) return;224 let cancelled = false;225 fetch(`/api/library/files/${file.id}`, { credentials: "same-origin" })226 .then((r) => r.text())227 .then((t) => {228 if (!cancelled) setText(t.length > 40_000 ? `${t.slice(0, 40_000)}\n… (${formatBytes(file.sizeBytes)} total)` : t);229 })230 .catch(() => {231 if (!cancelled) setText("Could not load the file.");232 });233 return () => {234 cancelled = true;235 setText(null);236 };237 }, [file, isText]);238 return (239 <ResponsiveDialog240 open={file !== null}241 onOpenChange={onOpenChange}242 title={file?.name ?? "Preview"}243 description={file ? `${kindLabel(file.kind)} · ${formatBytes(file.sizeBytes)}${file.estimatedTokens ? ` · ~${formatTokens(file.estimatedTokens)} tokens` : ""}` : undefined}244 size="lg"245 snap="full"246 footer={247 file ? (248 <div className="flex gap-2 sm:justify-end">249 <button type="button" className="tap inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md bg-bg-muted px-3 text-sm font-medium sm:h-9 sm:flex-none" onClick={() => onDownload(file)}>250 <Download className="size-4" /> Download251 </button>252 <button type="button" className="tap inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg sm:h-9 sm:flex-none" onClick={() => (onOpenChange(false), onUse(file))}>253 <MessageSquarePlus className="size-4" /> Use in new chat254 </button>255 </div>256 ) : undefined257 }258 >259 {file?.kind === "image" ? (260 // eslint-disable-next-line @next/next/no-img-element261 <img src={`/api/library/files/${file.id}`} alt={file.name} className="mx-auto max-h-[70dvh] w-auto max-w-full rounded-lg bg-bg-muted object-contain" />262 ) : isText ? (263 text === null ? (264 <Skeleton className="h-40" />265 ) : (266 <pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-lg border border-border bg-bg-subtle p-3 font-mono text-[12px] leading-5 text-fg-muted">{text}</pre>267 )268 ) : null}269 </ResponsiveDialog>270 );271}272