TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Upload } from "lucide-react";4import { Button, type ButtonProps } from "@/components/ui/button";5import { toast } from "@/components/ui/toast";6import { errorMessage } from "@/lib/client/humanize";7import { ClientApiError } from "@/lib/client/api";8import { LIBRARY_ACCEPT } from "./format";9import type { PublicProjectFile } from "@/lib/client/types";1011export const LIBRARY_MAX_BYTES = 10 * 1024 * 1024;1213/** Uploads files one by one to the library (multipart, same limits as chat attachments). Returns saved files + errors. */14export async function uploadLibraryFiles(files: File[], projectId?: string | null, onProgress?: (done: number, total: number) => void): Promise<{ saved: PublicProjectFile[]; errors: { name: string; message: string }[] }> {15 const saved: PublicProjectFile[] = [];16 const errors: { name: string; message: string }[] = [];17 let done = 0;18 for (const f of files) {19 if (f.size > LIBRARY_MAX_BYTES) {20 errors.push({ name: f.name, message: "Larger than 10 MB" });21 } else {22 const form = new FormData();23 form.append("file", f);24 if (projectId) form.append("projectId", projectId);25 try {26 const res = await fetch("/api/library/files", { method: "POST", body: form, credentials: "same-origin" });27 if (!res.ok) {28 const body = (await res.json().catch(() => ({}))) as { error?: { code?: string; message?: string } };29 throw new ClientApiError(res.status, body.error?.code ?? "HTTP_ERROR", body.error?.message ?? `Upload failed (${res.status})`);30 }31 const body = (await res.json()) as { file: PublicProjectFile };32 saved.push(body.file);33 } catch (e) {34 errors.push({ name: f.name, message: errorMessage(e) });35 }36 }37 done += 1;38 onProgress?.(done, files.length);39 }40 return { saved, errors };41}4243/** Summarises an upload batch in toasts. */44export function reportUpload(result: { saved: PublicProjectFile[]; errors: { name: string; message: string }[] }) {45 if (result.saved.length) toast.success(result.saved.length === 1 ? `Added “${result.saved[0].name}”` : `Added ${result.saved.length} files to the library`);46 for (const e of result.errors.slice(0, 3)) toast.error(`Could not upload ${e.name}`, e.message);47 if (result.errors.length > 3) toast.error(`${result.errors.length - 3} more files failed`);48}4950export function UploadButton({ projectId, onUploaded, children, size = "md", variant = "primary", className, ...rest }: { projectId?: string | null; onUploaded?: (files: PublicProjectFile[]) => unknown } & Omit<ButtonProps, "onClick">) {51 const inputRef = React.useRef<HTMLInputElement>(null);52 const [busy, setBusy] = React.useState<{ done: number; total: number } | null>(null);5354 const onFiles = async (list: FileList | null) => {55 const files = list ? Array.from(list) : [];56 if (!files.length) return;57 setBusy({ done: 0, total: files.length });58 try {59 const result = await uploadLibraryFiles(files, projectId, (done, total) => setBusy({ done, total }));60 reportUpload(result);61 if (result.saved.length) await onUploaded?.(result.saved);62 } finally {63 setBusy(null);64 if (inputRef.current) inputRef.current.value = "";65 }66 };6768 return (69 <>70 <input ref={inputRef} type="file" multiple accept={LIBRARY_ACCEPT} className="sr-only" tabIndex={-1} aria-hidden onChange={(e) => void onFiles(e.target.files)} />71 <Button size={size} variant={variant} className={className} loading={Boolean(busy)} onClick={() => inputRef.current?.click()} {...rest}>72 {busy ? (73 <span>74 {busy.done}/{busy.total}75 </span>76 ) : (77 children ?? (78 <>79 <Upload /> Upload80 </>81 )82 )}83 </Button>84 </>85 );86}87