"use client"; import * as React from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Globe, Library, Search, Upload } from "lucide-react"; import { useApi } from "@/lib/client/api"; import { errorMessage } from "@/lib/client/humanize"; import { useIsMobile } from "@/lib/client/hooks"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ChipRow } from "@/components/ui/segmented"; import { EmptyState } from "@/components/ui/misc"; import type { PublicProject, PublicProjectFile } from "@/lib/client/types"; import { formatTokens, cn } from "@/lib/utils"; import { WorkspacePageHeader, WorkspacePageBody } from "@/components/projects/page-header"; import { ProjectIcon } from "@/components/projects/project-icon"; import { FileList } from "./file-list"; import { UploadButton, uploadLibraryFiles, reportUpload } from "./upload-button"; import { formatBytes, kindLabel } from "./format"; const KINDS = ["image", "pdf", "text", "code", "csv", "json"] as const; type KindFilter = (typeof KINDS)[number] | "all"; /** `/app/library`: every stored file, filterable by project (global / per project) and kind, with drag & drop upload. */ export function LibraryView() { const router = useRouter(); const params = useSearchParams(); const isMobile = useIsMobile(); const initialScope = params.get("project") ?? "all"; const [scope, setScope] = React.useState(initialScope); const [kind, setKind] = React.useState("all"); const [q, setQ] = React.useState(""); const [dragging, setDragging] = React.useState(false); const [uploading, setUploading] = React.useState<{ done: number; total: number } | null>(null); const files = useApi<{ files: PublicProjectFile[] }>("/api/library/files"); const projects = useApi<{ projects: PublicProject[] }>("/api/projects?archived=1"); const all = React.useMemo(() => files.data?.files ?? [], [files.data]); const projectList = React.useMemo(() => projects.data?.projects ?? [], [projects.data]); const projectById = React.useMemo(() => new Map(projectList.map((p) => [p.id, p])), [projectList]); // Projects that have at least one file, plus the one selected via ?project= (so the chip exists even when empty). const projectChips = React.useMemo(() => { const ids = new Set(all.map((f) => f.projectId).filter((x): x is string => Boolean(x))); if (scope !== "all" && scope !== "none") ids.add(scope); return projectList.filter((p) => ids.has(p.id)); }, [all, projectList, scope]); const list = React.useMemo(() => { const s = q.trim().toLowerCase(); return all .filter((f) => (scope === "all" ? true : scope === "none" ? !f.projectId : f.projectId === scope)) .filter((f) => (kind === "all" ? true : f.kind === kind)) .filter((f) => (s ? f.name.toLowerCase().includes(s) || (f.description ?? "").toLowerCase().includes(s) || kindLabel(f.kind).toLowerCase().includes(s) : true)); }, [all, scope, kind, q]); const totals = React.useMemo(() => ({ bytes: list.reduce((n, f) => n + f.sizeBytes, 0), tokens: list.reduce((n, f) => n + (f.estimatedTokens ?? 0), 0) }), [list]); const kindCounts = React.useMemo(() => { const m = new Map(); for (const f of all) m.set(f.kind, (m.get(f.kind) ?? 0) + 1); return m; }, [all]); const uploadTarget = scope === "all" || scope === "none" ? null : scope; const changeScope = (s: string) => { setScope(s); router.replace(s === "all" ? "/app/library" : `/app/library?project=${encodeURIComponent(s)}`); }; const onDrop = async (e: React.DragEvent) => { e.preventDefault(); setDragging(false); const dropped = Array.from(e.dataTransfer.files ?? []); if (!dropped.length) return; setUploading({ done: 0, total: dropped.length }); try { const res = await uploadLibraryFiles(dropped, uploadTarget, (done, total) => setUploading({ done, total })); reportUpload(res); await files.mutate(); } finally { setUploading(null); } }; const scopeLabel = scope === "all" ? "All files" : scope === "none" ? "Global library" : projectById.get(scope)?.name ?? "Project"; return (
{ if (isMobile || !e.dataTransfer.types.includes("Files")) return; e.preventDefault(); setDragging(true); }} onDragLeave={(e) => { if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; setDragging(false); }} onDrop={onDrop} > n + f.sizeBytes, 0))}` : undefined} actions={ files.mutate()} />} /> {all.length > 0 ? (
setQ(e.target.value)} placeholder="Search files…" className="h-10 pl-8 sm:h-9 sm:max-w-sm" aria-label="Search files" />
, count: all.filter((f) => !f.projectId).length }, ...projectChips.map((p) => ({ value: p.id, label: p.name, icon: , count: all.filter((f) => f.projectId === p.id).length })), ]} /> {all.length > 3 ? kindCounts.get(k)).map((k) => ({ value: k as KindFilter, label: kindLabel(k), count: kindCounts.get(k) }))]} /> : null}
) : null}
0 && "mt-4")}> {files.error ? ( files.mutate()}>Retry} /> ) : !files.isLoading && all.length === 0 ? ( } title="Your library is empty" description="Upload PDFs, images, code or data once and attach them to any chat. Files saved to a project stay grouped with it." action={ files.mutate()} />} /> ) : ( <> {all.length > 0 ? (

{scopeLabel} · {list.length} file{list.length === 1 ? "" : "s"} · {formatBytes(totals.bytes)} {totals.tokens ? ` · ~${formatTokens(totals.tokens)} tokens` : ""}

) : null} files.mutate()} contextProjectId={uploadTarget} emptyTitle={q ? "No matching files" : kind !== "all" ? `No ${kindLabel(kind).toLowerCase()} files here` : "No files here yet"} emptyDescription={q ? "Try another search." : "Upload files to this scope, or pick another project."} emptyAction={!q ? files.mutate()} /> : undefined} /> )}
{!isMobile ?

Drop files anywhere on this page to upload them{uploadTarget ? ` to ${scopeLabel}` : ""}. Images, PDF, text, code, CSV, JSON · 10 MB max.

: null}
{dragging || uploading ? (

{uploading ? `Uploading ${uploading.done}/${uploading.total}…` : `Drop to upload${uploadTarget ? ` to ${scopeLabel}` : ""}`}

) : null}
); }