SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
8.6 KB · 151 lines tsx
Raw Blame History
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