SPB Git
3.2 KB · 31 lines tsx
1"use client";2import { useRef, useState } from "react";3import { Paperclip, Upload, X } from "lucide-react";4import { Popover, PopoverTrigger, PopoverContent, Spinner } from "@/components/ui/primitives";5import { toast } from "@/components/ui/toast";6import { cn } from "@/lib/utils";7import type { FileRef } from "@/lib/databases/types";89export const isImage = (f: FileRef) => (f.mime ? f.mime.startsWith("image/") : /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(f.name));10export function FileChip({ f, onRemove, className }: { f: FileRef; onRemove?: () => void; className?: string }) {11  return <span className={cn("inline-flex max-w-full items-center gap-1 rounded-md border border-line bg-bg-elev px-1.5 py-0.5 text-[12px] leading-4", className)}><Paperclip className="size-3 shrink-0 text-fg-faint" /><a href={f.url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="truncate hover:text-accent">{f.name}</a>{onRemove && <button type="button" onClick={(e) => { e.stopPropagation(); onRemove(); }} className="-mr-0.5 rounded p-0.5 text-fg-faint hover:text-fg"><X className="size-3" /></button>}</span>;12}13export async function uploadFiles(files: File[], pageId: string): Promise<FileRef[]> {14  const out: FileRef[] = [];15  for (const file of files) { const fd = new FormData(); fd.append("file", file); fd.append("pageId", pageId); const res = await fetch("/api/attachments", { method: "POST", body: fd, credentials: "same-origin" }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? `Upload failed (${res.status})`); const j = await res.json(); out.push({ id: j.id, url: j.url, name: j.name, mime: j.mime }); }16  return out;17}18export function FilePopover({ value, pageId, onChange, open, onOpenChange, children }: { value: unknown; pageId: string; onChange: (v: FileRef[]) => void; open: boolean; onOpenChange: (o: boolean) => void; children: React.ReactNode }) {19  const files = (Array.isArray(value) ? value : []) as FileRef[]; const input = useRef<HTMLInputElement>(null); const [busy, setBusy] = useState(false);20  const add = async (list: FileList | null) => { if (!list?.length) return; setBusy(true); try { onChange([...files, ...(await uploadFiles([...list], pageId))]); } catch (e: any) { toast("Upload failed", { description: e?.message, tone: "danger" }); } finally { setBusy(false); if (input.current) input.current.value = ""; } };21  return (22    <Popover open={open} onOpenChange={onOpenChange}><PopoverTrigger asChild>{children}</PopoverTrigger>23      <PopoverContent className="w-72 p-2">24        <div className="flex flex-wrap gap-1">{files.map((f) => <FileChip key={f.id} f={f} onRemove={() => onChange(files.filter((x) => x.id !== f.id))} />)}{!files.length && <span className="px-1 text-[12px] text-fg-faint">No files.</span>}</div>25        <button type="button" disabled={busy} onClick={() => input.current?.click()} className="mt-2 flex h-9 w-full items-center justify-center gap-2 rounded-md border border-dashed border-line text-[13px] text-fg-muted hover:bg-bg-sunk">{busy ? <Spinner /> : <Upload className="size-4" />} Upload files</button>26        <input ref={input} type="file" multiple className="hidden" onChange={(e) => add(e.target.files)} />27      </PopoverContent>28    </Popover>29  );30}31
Files · 2c37cb5a3e2af18e5ba361996d453cab97da82c2
Menu

Theme