SPB Git

Repository Public

sidebar.tsx 5.7 KB 105 lines tsx

1'use client';2import Link from 'next/link';3import { usePathname } from 'next/navigation';4import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';5import { useMissions, usePendingCount } from '@/lib/queries';6import { useUi } from '@/stores/ui';7import { IconButton } from '@/components/ui/button';8import { Tooltip } from '@/components/ui/tooltip';9import { Dot } from '@/components/ui/badge';10import { RUN_STATUS, isActive } from '@/lib/status';11import { fmtUsd } from '@/lib/format';12import { BRAND } from '@/lib/brand';13import { cn } from '@/lib/cn';14import { NAV, NAV_GROUPS, activeHref } from './nav';15import { Logo } from './logo';1617export function Sidebar() {18  const pathname = usePathname();19  const current = activeHref(pathname);20  const collapsed = useUi((s) => s.sidebarCollapsed);21  const setUi = useUi((s) => s.set);22  const pending = usePendingCount();23  const active = (useMissions().data ?? []).filter((m) => isActive(m.status));2425  return (26    <nav aria-label="Primary" data-collapsed={collapsed} className={cn('flex h-full shrink-0 flex-col border-r border-line bg-bg transition-[width] duration-150', collapsed ? 'w-14' : 'w-60')}>27      <div className={cn('flex h-14 items-center', collapsed ? 'justify-center' : 'gap-2.5 px-4')}>28        <Logo className="size-7 shrink-0" />29        {!collapsed && <span className="flex-1 truncate text-[14px] font-bold tracking-tight" data-testid="wordmark">{BRAND.product}</span>}30        {!collapsed && (31          <IconButton label="Collapse sidebar" size="sm" onClick={() => setUi({ sidebarCollapsed: true })}>32            <PanelLeftClose className="size-4" />33          </IconButton>34        )}35      </div>36      {NAV_GROUPS.map((g) => (37        <div key={g.label} className={cn('pt-3', collapsed ? 'px-0' : 'px-2.5')}>38          {!collapsed && <p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-faint">{g.label}</p>}39          <ul className={cn('flex flex-col gap-0.5', collapsed && 'items-center')}>40            {NAV.filter((n) => g.items.includes(n.href)).map((n) => {41              const on = current === n.href;42              const badge = n.href === '/approvals/' && pending > 0 ? pending : 0;43              const link = (44                <Link45                  href={n.href}46                  aria-current={on ? 'page' : undefined}47                  aria-label={collapsed ? `${n.label}${badge ? `, ${badge} pending` : ''}` : undefined}48                  data-testid={`nav-${n.label.toLowerCase()}`}49                  className={cn(50                    'relative flex h-[34px] items-center gap-2.5 rounded-[10px] text-[13px] font-medium transition-colors',51                    collapsed ? 'w-9 justify-center' : 'px-2.5',52                    on ? 'bg-surface text-text shadow-card' : 'text-text-2 hover:bg-hover hover:text-text',53                  )}54                >55                  <n.icon className={cn('size-4 shrink-0', on ? 'text-accent' : 'text-muted')} strokeWidth={on ? 2.3 : 2} aria-hidden />56                  {!collapsed && <span className="flex-1">{n.label}</span>}57                  {badge > 0 && (58                    <span data-testid="pending-badge" aria-label={`${badge} pending`} className={cn('tnum flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-info-ink px-1 text-[11px] font-bold text-info-fg', collapsed && 'absolute -right-1 -top-1 h-4 min-w-4 text-[10px]')}>59                      {badge > 99 ? '99+' : badge}60                    </span>61                  )}62                </Link>63              );64              return <li key={n.href}>{collapsed ? <Tooltip content={n.label} side="right">{link}</Tooltip> : link}</li>;65            })}66          </ul>67        </div>68      ))}69      <div className="mx-4 mt-3 h-px bg-line" />70      <div className={cn('flex min-h-0 flex-1 flex-col py-3', collapsed ? 'items-center' : 'px-2.5')}>71        {!collapsed && <p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-faint">Active missions</p>}72        <div className="no-scrollbar min-h-0 flex-1 overflow-y-auto" data-testid="sidebar-missions">73          {!collapsed && active.length === 0 && <p className="px-2.5 py-1.5 text-xs text-muted">Nothing running</p>}74          {active.map((m) => {75            const st = RUN_STATUS[m.status];76            const on = pathname.startsWith('/mission') && typeof location !== 'undefined' && location.search.includes(m.id);77            return collapsed ? (78              <Tooltip key={m.id} content={`${m.title} — ${st.label}`} side="right">79                <Link href={`/mission/?id=${m.id}`} aria-label={`Open mission ${m.title}`} className="flex size-8 items-center justify-center rounded-sm hover:bg-hover">80                  <Dot tone={st.tone} pulse={st.pulse} />81                </Link>82              </Tooltip>83            ) : (84              <Link key={m.id} href={`/mission/?id=${m.id}`} className={cn('flex min-h-[38px] items-center gap-2.5 rounded-[10px] px-2.5 py-1 hover:bg-hover', on && 'bg-hover')}>85                <Dot tone={st.tone} pulse={st.pulse} label={st.label} />86                <span className="min-w-0 flex-1">87                  <span className="block truncate text-[12.5px] font-medium text-text">{m.title}</span>88                  <span className="tnum block truncate text-[11px] text-muted">{st.label} · {fmtUsd(m.spentUsd)}</span>89                </span>90              </Link>91            );92          })}93        </div>94      </div>95      {collapsed && (96        <div className="flex justify-center border-t border-line py-2">97          <IconButton label="Expand sidebar" tooltipSide="right" onClick={() => setUi({ sidebarCollapsed: false })}>98            <PanelLeftOpen className="size-4" />99          </IconButton>100        </div>101      )}102    </nav>103  );104}105
Files main
Menu

Theme