Repository Public
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