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%
13.4 KB · 272 lines tsx
Raw Blame History
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