SPB Git
7.8 KB · 45 lines tsx
1"use client";2import { Command } from "cmdk";3import { useEffect, useState } from "react";4import { useRouter } from "next/navigation";5import { useQuery } from "@tanstack/react-query";6import { FileText, Plus, Sparkles, Home, Share2, Settings, Search, BarChart3, Trash2, Database, FlaskConical, Moon, Sun, Columns3, Bot, Inbox } from "lucide-react";7import { useUI } from "@/lib/client/store";8import { api } from "@/lib/client/api";9import type { TreeNode } from "./types";10import { cn } from "@/lib/utils";11export function CommandPalette({ tree }: { tree: TreeNode[] }) {12  const ui = useUI(); const r = useRouter(); const [q, setQ] = useState(""); const [mode, setMode] = useState<"quick" | "full">("quick");13  useEffect(() => { if (!ui.paletteOpen) { setQ(""); setMode("quick"); } }, [ui.paletteOpen]);14  const { data: hits = [] } = useQuery({ queryKey: ["search", mode, q], queryFn: () => api(`/api/search?mode=${mode === "full" ? "hybrid" : "quick"}&q=${encodeURIComponent(q)}&limit=12`), enabled: ui.paletteOpen && (mode === "full" ? q.length > 2 : true), staleTime: 5000 });15  const go = (href: string) => { ui.set({ paletteOpen: false }); r.push(href); };16  const run = (fn: () => void) => { ui.set({ paletteOpen: false }); fn(); };17  if (!ui.paletteOpen) return null;18  const Item = ({ onSelect, icon, children, hint, value }: { onSelect: () => void; icon: React.ReactNode; children: React.ReactNode; hint?: string; value: string }) => <Command.Item value={value} onSelect={onSelect} className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-[13px] data-[selected=true]:bg-bg-sunk [&_svg]:size-4 [&_svg]:text-fg-muted"><span className="w-4 text-center">{icon}</span><span className="min-w-0 flex-1 truncate">{children}</span>{hint && <span className="text-[11px] text-fg-faint">{hint}</span>}</Command.Item>;19  return (20    <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 p-4 pt-[12vh] backdrop-blur-[2px]" onClick={() => ui.set({ paletteOpen: false })}>21      <Command shouldFilter={mode === "quick"} className="w-full max-w-xl overflow-hidden rounded-xl border border-line bg-bg-elev shadow-pop fade-up" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Escape") ui.set({ paletteOpen: false }); if (e.key === "Tab") { e.preventDefault(); setMode((m) => (m === "quick" ? "full" : "quick")); } }}>22        <div className="flex items-center gap-2 border-b border-line px-3"><Search className="size-4 text-fg-faint" /><Command.Input autoFocus value={q} onValueChange={setQ} placeholder={mode === "quick" ? "Search pages, run commands…" : "Semantic search inside content…"} className="h-11 flex-1 bg-transparent text-sm outline-none placeholder:text-fg-faint" /><button onClick={() => setMode((m) => (m === "quick" ? "full" : "quick"))} className={cn("rounded-md border px-1.5 py-0.5 text-[10px] font-medium", mode === "full" ? "border-accent/30 bg-accent-soft text-accent" : "border-line text-fg-faint")}>{mode === "full" ? "Content ⇥" : "Titles ⇥"}</button></div>23        <Command.List className="max-h-[60vh] overflow-y-auto p-1.5">24          <Command.Empty className="px-3 py-6 text-center text-sm text-fg-muted">{mode === "full" && q.length <= 2 ? "Type at least 3 characters" : "Nothing found"}</Command.Empty>25          {mode === "quick" ? (<>26            {q.trim() && <Command.Group heading="Search" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-fg-faint"><Item value={`search all ${q}`} icon={<Search />} onSelect={() => go(`/search?q=${encodeURIComponent(q)}`)}>Search content for “{q}”</Item></Command.Group>}27            {q.trim() && <Command.Group heading="Create" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-fg-faint"><Item value={`create ${q}`} icon={<Plus />} onSelect={() => run(async () => { const p = await api("/api/pages", { method: "POST", json: { title: q } }); r.push(`/p/${p.id}`); })}>New page “{q}”</Item><Item value={`ask ${q}`} icon={<Sparkles />} onSelect={() => run(() => ui.openAI({ prefill: q }))}>Ask AI “{q}”</Item></Command.Group>}28            <Command.Group heading="Pages" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-fg-faint">29              {(q ? (hits as any[]) : tree.slice(0, 8)).map((p: any) => <Item key={p.id} value={`${p.title} ${p.id}`} icon={p.icon ?? (p.type === "database" ? <Database /> : p.type === "research" ? <FlaskConical /> : <FileText />)} hint={p.section} onSelect={() => go(`/p/${p.id}`)}>{p.title || "Untitled"}</Item>)}30            </Command.Group>31            <Command.Group heading="Go to" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-fg-faint">32              <Item value="home" icon={<Home />} onSelect={() => go("/")}>Home</Item><Item value="tasks inbox" icon={<Inbox />} onSelect={() => go("/inbox")}>Tasks</Item><Item value="graph" icon={<Share2 />} onSelect={() => go("/graph")}>Knowledge graph</Item><Item value="ai models benchmark" icon={<BarChart3 />} onSelect={() => go("/ai/models")}>AI models dashboard</Item><Item value="compare models" icon={<Columns3 />} onSelect={() => go("/ai/compare")}>Compare models</Item><Item value="agents" icon={<Bot />} onSelect={() => go("/ai/agents")}>Agents</Item><Item value="trash" icon={<Trash2 />} onSelect={() => go("/trash")}>Trash</Item><Item value="settings" icon={<Settings />} onSelect={() => go("/settings")}>Settings</Item>33            </Command.Group>34            <Command.Group heading="Actions" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-fg-faint">35              <Item value="new page" icon={<Plus />} hint="⌘⇧N" onSelect={() => run(async () => { const p = await api("/api/pages", { method: "POST", json: { title: "" } }); r.push(`/p/${p.id}`); })}>New page</Item><Item value="new database" icon={<Database />} onSelect={() => run(async () => { const p = await api("/api/pages", { method: "POST", json: { title: "New database", type: "database", section: "databases" } }); r.push(`/p/${p.id}`); })}>New database</Item><Item value="open ai assistant" icon={<Sparkles />} hint="⌘/" onSelect={() => run(() => ui.openAI())}>Open AI assistant</Item><Item value="dark mode" icon={<Moon />} onSelect={() => run(() => ui.setTheme("dark"))}>Dark mode</Item><Item value="light mode" icon={<Sun />} onSelect={() => run(() => ui.setTheme("light"))}>Light mode</Item>36            </Command.Group>37          </>) : (38            (hits as any[]).map((h: any) => <Command.Item key={h.chunkId} value={h.chunkId} onSelect={() => go(`/p/${h.pageId}${h.blockId ? `#block-${h.blockId}` : ""}`)} className="cursor-pointer rounded-md px-2 py-2 data-[selected=true]:bg-bg-sunk"><div className="flex items-center gap-2 text-[13px] font-medium"><span>{h.icon ?? "📄"}</span><span className="truncate">{h.title}</span>{h.heading && <span className="truncate text-fg-faint">› {h.heading}</span>}<span className="ml-auto text-[10px] text-fg-faint">{h.semantic ? `sem ${(h.semantic * 100).toFixed(0)}%` : "kw"}</span></div><div className="mt-0.5 line-clamp-2 text-[12px] text-fg-muted">{h.content}</div></Command.Item>)39          )}40        </Command.List>41      </Command>42    </div>43  );44}45
Files · main
Menu

Theme