spb/note-spboucher-ai
Public
1"use client";2import { useRef, useState } from "react";3import { useQueryClient } from "@tanstack/react-query";4import { useRouter } from "next/navigation";5import { Search, Filter, ArrowUpDown, Layers, SlidersHorizontal, Plus, MoreHorizontal, Download, Upload, Copy, LayoutTemplate, X } from "lucide-react";6import { Button } from "@/components/ui/button";7import { Popover, PopoverTrigger, PopoverContent, Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from "@/components/ui/primitives";8import { toast } from "@/components/ui/toast";9import { api } from "@/lib/client/api";10import { cn } from "@/lib/utils";11import { useDb } from "./hooks";12import { FilterBuilder, SortBuilder, describeFilter } from "./FilterBuilder";13import { PropertyManager } from "./PropertyManager";14import { Sel } from "./meta";1516export function Toolbar({ search, setSearch, onShowTemplates }: { search: string; setSearch: (s: string) => void; onShowTemplates: () => void }) {17 const ctx = useDb(); const qc = useQueryClient(); const r = useRouter(); const [open, setOpen] = useState<string | null>(null); const [showSearch, setShowSearch] = useState(!!search); const file = useRef<HTMLInputElement>(null);18 const nFilters = describeFilter(ctx.view.config.filter); const nSorts = ctx.view.config.sorts?.length ?? 0; const groupable = ctx.props.filter((p) => ["select", "status", "checkbox"].includes(p.type));19 const canGroup = ["board", "table", "list"].includes(ctx.view.type); const gb = ctx.view.config.groupBy ?? "";20 const importCsv = async (f: File | undefined) => { if (!f) return; const fd = new FormData(); fd.append("file", f); try { const res = await fetch(`/api/databases/${ctx.db.id}/import`, { method: "POST", body: fd, credentials: "same-origin" }); const j = await res.json(); if (!res.ok) throw new Error(j.error); toast(`Imported ${j.created} rows`, { description: j.properties.length ? `New properties: ${j.properties.join(", ")}` : undefined, tone: "success" }); ctx.refresh(); } catch (e: any) { toast("Import failed", { description: e?.message, tone: "danger" }); } finally { if (file.current) file.current.value = ""; } };21 const pill = (active: boolean) => cn("flex h-8 items-center gap-1 rounded-md px-2 text-[12px] text-fg-muted hover:bg-bg-sunk hover:text-fg [&_svg]:size-3.5", active && "bg-accent-soft text-accent hover:bg-accent-soft hover:text-accent");22 return (23 <div className="flex flex-wrap items-center gap-0.5">24 {showSearch ? <div className="flex h-8 items-center gap-1 rounded-md border border-line bg-bg-elev px-2"><Search className="size-3.5 text-fg-faint" /><input autoFocus value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search rows…" className="w-28 bg-transparent text-[12px] outline-none sm:w-40" onKeyDown={(e) => { if (e.key === "Escape") { setSearch(""); setShowSearch(false); } }} /><button type="button" onClick={() => { setSearch(""); setShowSearch(false); }} className="text-fg-faint hover:text-fg"><X className="size-3.5" /></button></div> : <button type="button" onClick={() => setShowSearch(true)} className={pill(false)}><Search /></button>}25 <Popover open={open === "filter"} onOpenChange={(o) => setOpen(o ? "filter" : null)}><PopoverTrigger asChild><button type="button" className={pill(nFilters > 0)}><Filter />{nFilters > 0 ? `${nFilters} filter${nFilters > 1 ? "s" : ""}` : <span className="hidden sm:inline">Filter</span>}</button></PopoverTrigger><PopoverContent className="w-auto"><FilterBuilder /></PopoverContent></Popover>26 <Popover open={open === "sort"} onOpenChange={(o) => setOpen(o ? "sort" : null)}><PopoverTrigger asChild><button type="button" className={pill(nSorts > 0)}><ArrowUpDown />{nSorts > 0 ? `${nSorts} sort${nSorts > 1 ? "s" : ""}` : <span className="hidden sm:inline">Sort</span>}</button></PopoverTrigger><PopoverContent className="w-auto"><SortBuilder /></PopoverContent></Popover>27 {canGroup && <Popover open={open === "group"} onOpenChange={(o) => setOpen(o ? "group" : null)}><PopoverTrigger asChild><button type="button" className={pill(!!gb)}><Layers />{gb ? ctx.props.find((p) => p.id === gb)?.name ?? "Group" : <span className="hidden sm:inline">Group</span>}</button></PopoverTrigger><PopoverContent className="w-60"><div className="mb-1 text-[12px] font-medium text-fg-muted">Group by</div><Sel value={gb} onChange={(e) => ctx.patchView({ groupBy: e.target.value || null })} className="w-full"><option value="">None</option>{groupable.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Sel>{!groupable.length && <div className="mt-1 text-[11px] text-fg-faint">Add a select, status or checkbox property to group.</div>}</PopoverContent></Popover>}28 <Popover open={open === "props"} onOpenChange={(o) => setOpen(o ? "props" : null)}><PopoverTrigger asChild><button type="button" className={pill(false)}><SlidersHorizontal /><span className="hidden sm:inline">Properties</span></button></PopoverTrigger><PopoverContent className="w-auto"><PropertyManager onClose={() => setOpen(null)} /></PopoverContent></Popover>29 <Menu><MenuTrigger asChild><button type="button" className={pill(false)}><MoreHorizontal /></button></MenuTrigger>30 <MenuContent>31 <MenuItem onSelect={() => window.open(`/api/databases/${ctx.db.id}/query?format=csv&viewId=${ctx.view.id}`, "_blank")}><Download /> Export CSV (this view)</MenuItem>32 <MenuItem onSelect={() => file.current?.click()}><Upload /> Import CSV…</MenuItem>33 <MenuSeparator />34 <MenuItem onSelect={async () => { const d = await api(`/api/databases/${ctx.db.id}/duplicate`, { method: "POST", json: { withRows: false } }); qc.invalidateQueries({ queryKey: ["tree"] }); toast("Database structure duplicated"); r.push(`/p/${d.pageId}`); }}><Copy /> Duplicate (structure)</MenuItem>35 <MenuItem onSelect={async () => { const d = await api(`/api/databases/${ctx.db.id}/duplicate`, { method: "POST", json: { withRows: true } }); qc.invalidateQueries({ queryKey: ["tree"] }); toast("Database duplicated with rows"); r.push(`/p/${d.pageId}`); }}><Copy /> Duplicate with rows</MenuItem>36 <MenuItem onSelect={async () => { await api(`/api/databases/${ctx.db.id}`, { method: "PATCH", json: { isTemplate: !ctx.db.isTemplate } }); ctx.refresh(); toast(ctx.db.isTemplate ? "No longer a template" : "Marked as template"); }}><LayoutTemplate /> {ctx.db.isTemplate ? "Unmark template" : "Mark as template"}</MenuItem>37 <MenuItem onSelect={onShowTemplates}><LayoutTemplate /> Apply a built-in template…</MenuItem>38 </MenuContent></Menu>39 <input ref={file} type="file" accept=".csv,text/csv" className="hidden" onChange={(e) => importCsv(e.target.files?.[0])} />40 <Button size="sm" className="ml-1 h-8" onClick={() => ctx.addRow()}><Plus /> New</Button>41 </div>42 );43}44