SPB Git
9.4 KB · 82 lines tsx
1"use client";2import Link from "next/link";3import { usePathname, useRouter } from "next/navigation";4import { useMemo, useState } from "react";5import { Home, Search, Sparkles, FileText, Files, Database, FolderKanban, FlaskConical, Share2, Link2, ChevronRight, Plus, Settings, Trash2, Star, MoreHorizontal, Moon, Sun, Laptop, LogOut, BarChart3, Bot, Inbox, LayoutDashboard } from "lucide-react";6import { useQueryClient } from "@tanstack/react-query";7import type { TreeNode, ShellUser } from "./types";8import { cn } from "@/lib/utils";9import { useUI } from "@/lib/client/store";10import { api } from "@/lib/client/api";11import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from "@/components/ui/primitives";12import { toast } from "@/components/ui/toast";1314const SECTIONS: { id: string; label: string; icon: React.ReactNode; type?: string }[] = [15  { id: "notes", label: "My Notes", icon: <FileText /> }, { id: "documents", label: "Documents", icon: <Files /> }, { id: "databases", label: "Databases", icon: <Database />, type: "database" }, { id: "projects", label: "Projects", icon: <FolderKanban /> }, { id: "research", label: "Research", icon: <FlaskConical />, type: "research" },16];17export function Sidebar({ tree, user, workspace, onNavigate }: { tree: TreeNode[]; user: ShellUser; workspace: { name: string }; onNavigate?: () => void }) {18  const pathname = usePathname(); const r = useRouter(); const qc = useQueryClient(); const ui = useUI();19  const byParent = useMemo(() => { const m = new Map<string | null, TreeNode[]>(); for (const n of tree) { const k = n.parentId; if (!m.has(k)) m.set(k, []); m.get(k)!.push(n); } return m; }, [tree]);20  const [open, setOpen] = useState<Record<string, boolean>>(() => { try { return JSON.parse(localStorage.getItem("note-tree-open") ?? "{}"); } catch { return {}; } });21  const toggle = (id: string) => setOpen((o) => { const n = { ...o, [id]: !o[id] }; localStorage.setItem("note-tree-open", JSON.stringify(n)); return n; });22  async function create(section: string, parentId: string | null = null, type?: string) {23    const p = await api("/api/pages", { method: "POST", json: { title: "", section, parentId, type: type ?? "page" } });24    qc.invalidateQueries({ queryKey: ["tree"] }); r.push(`/p/${p.id}`); onNavigate?.();25  }26  async function trash(n: TreeNode) { await api(`/api/pages/${n.id}`, { method: "DELETE" }); qc.invalidateQueries({ queryKey: ["tree"] }); toast("Moved to trash", { description: n.title }); if (pathname === `/p/${n.id}`) r.push("/"); }27  async function pin(n: TreeNode) { await api(`/api/pages/${n.id}`, { method: "PATCH", json: { pinned: !n.pinned } }); qc.invalidateQueries({ queryKey: ["tree"] }); }28  const Item = ({ n, depth }: { n: TreeNode; depth: number }) => {29    const kids = byParent.get(n.id) ?? []; const active = pathname === `/p/${n.id}`; const isOpen = open[n.id];30    return (31      <div>32        <div className={cn("group flex items-center gap-1 rounded-md pr-1 text-[13px] hover:bg-bg-sunk", active && "bg-bg-sunk font-medium text-fg")} style={{ paddingLeft: 6 + depth * 12 }}>33          <button className={cn("grid size-5 shrink-0 place-items-center rounded text-fg-faint hover:bg-line", !kids.length && "invisible")} onClick={() => toggle(n.id)}><ChevronRight className={cn("size-3.5 transition-transform", isOpen && "rotate-90")} /></button>34          <Link href={`/p/${n.id}`} onClick={onNavigate} className="flex min-w-0 flex-1 items-center gap-1.5 py-1 text-fg-muted hover:text-fg">35            <span className="w-4 shrink-0 text-center text-[13px]">{n.icon ?? (n.type === "database" ? "🗂" : n.type === "research" ? "🔬" : "📄")}</span><span className="truncate">{n.title || "Untitled"}</span>36          </Link>37          <Menu><MenuTrigger asChild><button className="invisible grid size-5 place-items-center rounded text-fg-faint hover:bg-line group-hover:visible data-[state=open]:visible"><MoreHorizontal className="size-3.5" /></button></MenuTrigger>38            <MenuContent><MenuItem onSelect={() => create(n.section, n.id)}><Plus /> Add sub-page</MenuItem><MenuItem onSelect={() => pin(n)}><Star /> {n.pinned ? "Unpin" : "Pin"}</MenuItem><MenuItem onSelect={async () => { const p = await api(`/api/pages/${n.id}?action=duplicate`, { method: "POST" }); qc.invalidateQueries({ queryKey: ["tree"] }); r.push(`/p/${p.id}`); }}><Files /> Duplicate</MenuItem><MenuSeparator /><MenuItem danger onSelect={() => trash(n)}><Trash2 /> Move to trash</MenuItem></MenuContent></Menu>39          <button className="invisible grid size-5 place-items-center rounded text-fg-faint hover:bg-line group-hover:visible" onClick={() => create(n.section, n.id)} title="Add sub-page"><Plus className="size-3.5" /></button>40        </div>41        {isOpen && kids.map((k) => <Item key={k.id} n={k} depth={depth + 1} />)}42      </div>43    );44  };45  const NavLink = ({ href, icon, label, onClick }: { href?: string; icon: React.ReactNode; label: string; onClick?: () => void }) => { const active = href && (pathname === href); const cls = cn("flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] text-fg-muted hover:bg-bg-sunk hover:text-fg [&_svg]:size-4", active && "bg-bg-sunk text-fg font-medium"); return href ? <Link href={href} className={cls} onClick={onNavigate}>{icon}{label}</Link> : <button className={cn(cls, "w-full")} onClick={onClick}>{icon}{label}</button>; };46  const pinned = tree.filter((n) => n.pinned);47  return (48    <div className="flex h-full flex-col safe-top">49      <div className="flex items-center gap-2 px-3 pt-3 pb-2">50        <Menu><MenuTrigger asChild><button className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-bg-sunk"><span className="grid size-6 place-items-center rounded-md bg-fg text-[12px] font-semibold text-bg">N</span><span className="truncate text-[13px] font-semibold">{workspace.name}</span></button></MenuTrigger>51          <MenuContent align="start" className="w-56">52            <div className="px-2 py-1.5 text-xs text-fg-muted">{user.displayName} · @{user.username}</div><MenuSeparator />53            <MenuItem onSelect={() => ui.setTheme("light")}><Sun /> Light {ui.theme === "light" && "✓"}</MenuItem><MenuItem onSelect={() => ui.setTheme("dark")}><Moon /> Dark {ui.theme === "dark" && "✓"}</MenuItem><MenuItem onSelect={() => ui.setTheme("system")}><Laptop /> System {ui.theme === "system" && "✓"}</MenuItem><MenuSeparator />54            <MenuItem onSelect={() => r.push("/settings")}><Settings /> Settings</MenuItem>55            <MenuItem danger onSelect={async () => { await api("/api/auth/logout", { method: "POST" }); window.location.href = "/login"; }}><LogOut /> Sign out</MenuItem>56          </MenuContent></Menu>57      </div>58      <div className="px-2 space-y-0.5">59        <NavLink href="/" icon={<Home />} label="Home" />60        <NavLink href="/search" icon={<Search />} label="Search" />61        <NavLink icon={<Sparkles />} label="AI Assistant" onClick={() => ui.openAI()} />62        <NavLink href="/inbox" icon={<Inbox />} label="Tasks" />63      </div>64      <div className="mt-3 min-h-0 flex-1 overflow-y-auto px-2 pb-4">65        {pinned.length > 0 && (<div className="mb-3"><div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-faint">Pinned</div>{pinned.map((n) => <Link key={n.id} href={`/p/${n.id}`} onClick={onNavigate} className={cn("flex items-center gap-1.5 rounded-md px-2 py-1 text-[13px] text-fg-muted hover:bg-bg-sunk hover:text-fg", pathname === `/p/${n.id}` && "bg-bg-sunk text-fg")}><Star className="size-3.5 text-warning" /><span className="truncate">{n.title || "Untitled"}</span></Link>)}</div>)}66        <div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-faint">Private</div>67        {SECTIONS.map((s) => { const roots = (byParent.get(null) ?? []).filter((n) => n.section === s.id); const isOpen = open[`sec:${s.id}`] ?? true; return (68          <div key={s.id} className="mb-1">69            <div className="group flex items-center gap-1 rounded-md pr-1 hover:bg-bg-sunk">70              <button className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-[13px] text-fg-muted hover:text-fg [&_svg]:size-4" onClick={() => toggle(`sec:${s.id}`)}>{s.icon}<span className="flex-1 truncate text-left">{s.label}</span><ChevronRight className={cn("size-3.5 text-fg-faint transition-transform", isOpen && "rotate-90")} /></button>71              <button className="invisible grid size-5 place-items-center rounded text-fg-faint hover:bg-line group-hover:visible" title={`New in ${s.label}`} onClick={() => create(s.id, null, s.type)}><Plus className="size-3.5" /></button>72            </div>73            {isOpen && (roots.length ? roots.map((n) => <Item key={n.id} n={n} depth={1} />) : <button onClick={() => create(s.id, null, s.type)} className="ml-7 py-1 text-[12px] text-fg-faint hover:text-fg-muted">+ New {s.type ?? "page"}</button>)}74          </div>); })}75        <div className="mt-3 px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-faint">Knowledge</div>76        <NavLink href="/graph" icon={<Share2 />} label="Graph" /><NavLink href="/backlinks" icon={<Link2 />} label="Backlinks" /><NavLink href="/ai/models" icon={<BarChart3 />} label="AI models" /><NavLink href="/ai/compare" icon={<LayoutDashboard />} label="Compare models" /><NavLink href="/ai/agents" icon={<Bot />} label="Agents" />77      </div>78      <div className="border-t border-line px-2 py-2 space-y-0.5"><NavLink href="/trash" icon={<Trash2 />} label="Trash" /><NavLink href="/settings" icon={<Settings />} label="Settings" /></div>79    </div>80  );81}82
Files · 2c37cb5a3e2af18e5ba361996d453cab97da82c2
Menu

Theme