TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { useRouter, useSearchParams } from "next/navigation";4import { Globe, Library, Search, Upload } from "lucide-react";5import { useApi } from "@/lib/client/api";6import { errorMessage } from "@/lib/client/humanize";7import { useIsMobile } from "@/lib/client/hooks";8import { Button } from "@/components/ui/button";9import { Input } from "@/components/ui/input";10import { ChipRow } from "@/components/ui/segmented";11import { EmptyState } from "@/components/ui/misc";12import type { PublicProject, PublicProjectFile } from "@/lib/client/types";13import { formatTokens, cn } from "@/lib/utils";14import { WorkspacePageHeader, WorkspacePageBody } from "@/components/projects/page-header";15import { ProjectIcon } from "@/components/projects/project-icon";16import { FileList } from "./file-list";17import { UploadButton, uploadLibraryFiles, reportUpload } from "./upload-button";18import { formatBytes, kindLabel } from "./format";1920const KINDS = ["image", "pdf", "text", "code", "csv", "json"] as const;21type KindFilter = (typeof KINDS)[number] | "all";2223/** `/app/library`: every stored file, filterable by project (global / per project) and kind, with drag & drop upload. */24export function LibraryView() {25 const router = useRouter();26 const params = useSearchParams();27 const isMobile = useIsMobile();28 const initialScope = params.get("project") ?? "all";29 const [scope, setScope] = React.useState<string>(initialScope);30 const [kind, setKind] = React.useState<KindFilter>("all");31 const [q, setQ] = React.useState("");32 const [dragging, setDragging] = React.useState(false);33 const [uploading, setUploading] = React.useState<{ done: number; total: number } | null>(null);34 const files = useApi<{ files: PublicProjectFile[] }>("/api/library/files");35 const projects = useApi<{ projects: PublicProject[] }>("/api/projects?archived=1");3637 const all = React.useMemo(() => files.data?.files ?? [], [files.data]);38 const projectList = React.useMemo(() => projects.data?.projects ?? [], [projects.data]);39 const projectById = React.useMemo(() => new Map(projectList.map((p) => [p.id, p])), [projectList]);40 // Projects that have at least one file, plus the one selected via ?project= (so the chip exists even when empty).41 const projectChips = React.useMemo(() => {42 const ids = new Set(all.map((f) => f.projectId).filter((x): x is string => Boolean(x)));43 if (scope !== "all" && scope !== "none") ids.add(scope);44 return projectList.filter((p) => ids.has(p.id));45 }, [all, projectList, scope]);4647 const list = React.useMemo(() => {48 const s = q.trim().toLowerCase();49 return all50 .filter((f) => (scope === "all" ? true : scope === "none" ? !f.projectId : f.projectId === scope))51 .filter((f) => (kind === "all" ? true : f.kind === kind))52 .filter((f) => (s ? f.name.toLowerCase().includes(s) || (f.description ?? "").toLowerCase().includes(s) || kindLabel(f.kind).toLowerCase().includes(s) : true));53 }, [all, scope, kind, q]);54 const totals = React.useMemo(() => ({ bytes: list.reduce((n, f) => n + f.sizeBytes, 0), tokens: list.reduce((n, f) => n + (f.estimatedTokens ?? 0), 0) }), [list]);55 const kindCounts = React.useMemo(() => {56 const m = new Map<string, number>();57 for (const f of all) m.set(f.kind, (m.get(f.kind) ?? 0) + 1);58 return m;59 }, [all]);6061 const uploadTarget = scope === "all" || scope === "none" ? null : scope;62 const changeScope = (s: string) => {63 setScope(s);64 router.replace(s === "all" ? "/app/library" : `/app/library?project=${encodeURIComponent(s)}`);65 };6667 const onDrop = async (e: React.DragEvent) => {68 e.preventDefault();69 setDragging(false);70 const dropped = Array.from(e.dataTransfer.files ?? []);71 if (!dropped.length) return;72 setUploading({ done: 0, total: dropped.length });73 try {74 const res = await uploadLibraryFiles(dropped, uploadTarget, (done, total) => setUploading({ done, total }));75 reportUpload(res);76 await files.mutate();77 } finally {78 setUploading(null);79 }80 };8182 const scopeLabel = scope === "all" ? "All files" : scope === "none" ? "Global library" : projectById.get(scope)?.name ?? "Project";8384 return (85 <div86 className="relative flex h-full min-h-0 flex-col"87 onDragOver={(e) => {88 if (isMobile || !e.dataTransfer.types.includes("Files")) return;89 e.preventDefault();90 setDragging(true);91 }}92 onDragLeave={(e) => {93 if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;94 setDragging(false);95 }}96 onDrop={onDrop}97 >98 <WorkspacePageHeader title="Library" subtitle={all.length ? `${all.length} file${all.length === 1 ? "" : "s"} · ${formatBytes(all.reduce((n, f) => n + f.sizeBytes, 0))}` : undefined} actions={<UploadButton size="sm" projectId={uploadTarget} onUploaded={() => files.mutate()} />} />99 <WorkspacePageBody>100 {all.length > 0 ? (101 <div className="space-y-3">102 <div className="relative">103 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />104 <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search files…" className="h-10 pl-8 sm:h-9 sm:max-w-sm" aria-label="Search files" />105 </div>106 <ChipRow107 value={scope}108 onChange={changeScope}109 className="-mx-4 px-4 sm:mx-0 sm:px-0"110 options={[111 { value: "all", label: "All", count: all.length },112 { value: "none", label: "Global", icon: <Globe />, count: all.filter((f) => !f.projectId).length },113 ...projectChips.map((p) => ({ value: p.id, label: p.name, icon: <ProjectIcon icon={p.icon} color={p.color} size="sm" className="size-4 text-[11px]" />, count: all.filter((f) => f.projectId === p.id).length })),114 ]}115 />116 {all.length > 3 ? <ChipRow value={kind} onChange={setKind} className="-mx-4 px-4 sm:mx-0 sm:px-0" options={[{ value: "all" as KindFilter, label: "Any type" }, ...KINDS.filter((k) => kindCounts.get(k)).map((k) => ({ value: k as KindFilter, label: kindLabel(k), count: kindCounts.get(k) }))]} /> : null}117 </div>118 ) : null}119120 <div className={cn(all.length > 0 && "mt-4")}>121 {files.error ? (122 <EmptyState title="Could not load the library" description={errorMessage(files.error)} action={<Button variant="outline" onClick={() => files.mutate()}>Retry</Button>} />123 ) : !files.isLoading && all.length === 0 ? (124 <EmptyState icon={<Library />} 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={<UploadButton projectId={uploadTarget} onUploaded={() => files.mutate()} />} />125 ) : (126 <>127 {all.length > 0 ? (128 <p className="mb-2 text-[12px] text-fg-subtle tabular-nums">129 {scopeLabel} · {list.length} file{list.length === 1 ? "" : "s"} · {formatBytes(totals.bytes)}130 {totals.tokens ? ` · ~${formatTokens(totals.tokens)} tokens` : ""}131 </p>132 ) : null}133 <FileList files={files.data ? list : undefined} loading={files.isLoading} projects={projectList} showProject={scope === "all"} onChanged={() => 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 ? <UploadButton projectId={uploadTarget} onUploaded={() => files.mutate()} /> : undefined} />134 </>135 )}136 </div>137 {!isMobile ? <p className="mt-6 text-center text-[12px] text-fg-subtle">Drop files anywhere on this page to upload them{uploadTarget ? ` to ${scopeLabel}` : ""}. Images, PDF, text, code, CSV, JSON · 10 MB max.</p> : null}138 </WorkspacePageBody>139140 {dragging || uploading ? (141 <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-bg/80 backdrop-blur-[2px]" aria-live="polite">142 <div className="flex flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-accent bg-bg-elevated px-8 py-6 text-center">143 <Upload className="size-6 text-accent" />144 <p className="text-sm font-medium">{uploading ? `Uploading ${uploading.done}/${uploading.total}…` : `Drop to upload${uploadTarget ? ` to ${scopeLabel}` : ""}`}</p>145 </div>146 </div>147 ) : null}148 </div>149 );150}151