SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
13.1 KB · 260 lines tsx
Raw Blame History
1'use client';2import { PanelRightClose, PanelRightOpen, Pause, Play, SlidersHorizontal } from 'lucide-react';3import Link from 'next/link';4import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';5import { Hint } from '@/components/ui/hint';6import { Sheet } from '@/components/ui/sheet';7import { cn } from '@/lib/cn';89/*10  Terminal layout primitives (density-aware through the CSS variables in globals.css):11  - TerminalLayout: desktop 3-pane (filter rail 15rem sticky · main · inspector 22rem sticky, collapsible, state in localStorage);12    mobile: filters and inspector in bottom sheets.13  - FilterSheet: the mobile bottom sheet used for filters (also usable standalone).14  - DataStrip: horizontal live counters with definition tooltips.15  - Ticker: scrolling strip of recent events, pausable, reduced-motion aware.16  - SectionNav: sticky in-page anchor nav with the active section highlighted.17*/1819const INSPECTOR_KEY = 'aia-inspector';2021export function TerminalLayout({22  filters,23  children,24  inspector,25  filtersTitle = 'Filters',26  inspectorTitle = 'Inspector',27  storageKey = INSPECTOR_KEY,28  className,29  filterCount,30  wide = true,31}: {32  filters?: ReactNode;33  children: ReactNode;34  inspector?: ReactNode;35  filtersTitle?: string;36  inspectorTitle?: string;37  /** localStorage key for the inspector collapsed state (per page when needed). */38  storageKey?: string;39  className?: string;40  /** Number of active filters, shown on the mobile filter button. */41  filterCount?: number;42  wide?: boolean;43}) {44  const [collapsed, setCollapsed] = useState(false);45  const [ready, setReady] = useState(false);46  const [filtersOpen, setFiltersOpen] = useState(false);47  const [inspectorOpen, setInspectorOpen] = useState(false);48  useEffect(() => {49    try {50      setCollapsed(localStorage.getItem(storageKey) === 'collapsed');51    } catch {52      /* ignore */53    }54    setReady(true);55  }, [storageKey]);56  const toggle = useCallback(() => {57    setCollapsed((c) => {58      try {59        localStorage.setItem(storageKey, c ? 'open' : 'collapsed');60      } catch {61        /* ignore */62      }63      return !c;64    });65  }, [storageKey]);66  const showInspector = !!inspector && (!ready || !collapsed);67  return (68    <div className={cn('container-x mx-auto w-full', wide ? 'max-w-[1600px]' : 'max-w-[1280px]', className)}>69      {/* mobile toolbar */}70      {(filters || inspector) && (71        <div className="flex items-center gap-2 border-b border-rule py-2 lg:hidden">72          {filters && (73            <button type="button" onClick={() => setFiltersOpen(true)} className="inline-flex h-10 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink" aria-haspopup="dialog" data-open-filters>74              <SlidersHorizontal className="size-4" aria-hidden /> {filtersTitle}75              {filterCount ? <span className="tnum ml-1 rounded-sm bg-accent-soft px-1.5 text-xs text-accent">{filterCount}</span> : null}76            </button>77          )}78          {inspector && (79            <button type="button" onClick={() => setInspectorOpen(true)} className="ml-auto inline-flex h-10 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink" aria-haspopup="dialog" data-open-inspector>80              <PanelRightOpen className="size-4" aria-hidden /> {inspectorTitle}81            </button>82          )}83        </div>84      )}85      <div className={cn('lg:grid lg:gap-8', filters && showInspector ? 'lg:grid-cols-[15rem_minmax(0,1fr)_22rem]' : filters ? 'lg:grid-cols-[15rem_minmax(0,1fr)]' : showInspector ? 'lg:grid-cols-[minmax(0,1fr)_22rem]' : '')}>86        {filters && (87          <aside className="terminal-rail hidden lg:block" aria-label={filtersTitle}>88            <p className="eyebrow mb-2">{filtersTitle}</p>89            {filters}90          </aside>91        )}92        <div className="min-w-0">93          {inspector && (94            <div className="hidden justify-end lg:flex">95              <button type="button" onClick={toggle} className="-mt-1 mb-1 inline-flex h-8 items-center gap-1 text-xs text-ink-3 hover:text-ink" aria-pressed={!collapsed} aria-label={collapsed ? `Show ${inspectorTitle.toLowerCase()}` : `Hide ${inspectorTitle.toLowerCase()}`} data-toggle-inspector>96                {collapsed ? <PanelRightOpen className="size-3.5" aria-hidden /> : <PanelRightClose className="size-3.5" aria-hidden />}97                {collapsed ? inspectorTitle : 'Hide'}98              </button>99            </div>100          )}101          {children}102        </div>103        {showInspector && (104          <aside className="terminal-rail hidden lg:block" aria-label={inspectorTitle}>105            <p className="eyebrow mb-2">{inspectorTitle}</p>106            {inspector}107          </aside>108        )}109      </div>110      {filters && (111        <FilterSheet open={filtersOpen} onClose={() => setFiltersOpen(false)} title={filtersTitle}>112          {filters}113        </FilterSheet>114      )}115      {inspector && (116        <Sheet open={inspectorOpen} onClose={() => setInspectorOpen(false)} side="bottom" eyebrow="Inspector" title={inspectorTitle}>117          {inspector}118        </Sheet>119      )}120    </div>121  );122}123124/** Mobile bottom sheet for filters; the filter form is server-rendered by the page and simply moved inside. */125export function FilterSheet({ open, onClose, title = 'Filters', children }: { open: boolean; onClose: () => void; title?: string; children: ReactNode }) {126  return (127    <Sheet open={open} onClose={onClose} side="bottom" eyebrow="Refine" title={title} footer={<button type="button" onClick={onClose} className="h-11 w-full bg-ink text-sm font-medium text-canvas">Done</button>}>128      {children}129    </Sheet>130  );131}132133/* ---------------------------------------------------------------------------------------------------------- DataStrip */134export type StripItem = { label: string; value: ReactNode; hint?: ReactNode; definition?: string; href?: string; delta?: { value: string; tone?: 'positive' | 'negative' | 'neutral' }; live?: boolean };135136/** Horizontal live counters strip: label + big tabular number + optional delta and a "How counted" definition tooltip. */137export function DataStrip({ items, className, dense = false }: { items: StripItem[]; className?: string; dense?: boolean }) {138  return (139    <div className={cn('no-scrollbar -mx-4 overflow-x-auto px-4 md:mx-0 md:px-0', className)} role="list" aria-label="Live counters">140      <div className={cn('flex min-w-max border-y border-rule md:grid md:min-w-0', items.length > 4 ? 'md:grid-cols-4 xl:grid-cols-[repeat(var(--n),minmax(0,1fr))]' : 'md:grid-cols-[repeat(var(--n),minmax(0,1fr))]')} style={{ ['--n' as string]: items.length }}>141        {items.map((it, i) => {142          const body = (143            <>144              <p className="eyebrow flex flex-wrap items-center gap-0.5 leading-tight">145                {it.live && <span className="dot mr-1" aria-hidden />}146                {it.label}147                {it.definition && <Hint text={it.definition} align={i >= items.length - 2 ? 'right' : 'left'} />}148              </p>149              <p className={cn('tnum mt-0.5 font-semibold leading-none tracking-tight text-ink', dense ? 'text-[20px]' : 'text-[22px] md:text-[26px]')}>{it.value}</p>150              {(it.delta || it.hint) && (151                <p className="mt-1 flex items-center gap-1.5 whitespace-nowrap text-[11px] text-ink-3">152                  {it.delta && <span className={cn('tnum font-medium', it.delta.tone === 'positive' && 'text-positive', it.delta.tone === 'negative' && 'text-danger', (!it.delta.tone || it.delta.tone === 'neutral') && 'text-accent-2')}>{it.delta.value}</span>}153                  {it.hint}154                </p>155              )}156            </>157          );158          const cls = cn('block min-w-[9rem] border-r border-rule px-3 py-2.5 last:border-r-0 md:min-w-0 md:[&:nth-child(4n)]:border-r-0 md:[&:nth-child(4n+1)]:pl-0 md:[&:nth-child(n+5)]:border-t xl:[&:nth-child(4n)]:border-r xl:[&:nth-child(4n+1)]:pl-3 xl:[&:nth-child(n+5)]:border-t-0 xl:first:pl-0 xl:last:border-r-0', items.length <= 4 && 'md:first:pl-0', it.href && 'hover:bg-surface-2');159          return it.href ? (160            <Link key={it.label} href={it.href} className={cls} role="listitem">161              {body}162            </Link>163          ) : (164            <div key={it.label} className={cls} role="listitem">165              {body}166            </div>167          );168        })}169      </div>170    </div>171  );172}173174/* ------------------------------------------------------------------------------------------------------------- Ticker */175export type TickerItem = { id: string; label: ReactNode; href?: string; tone?: 'new' | 'price' | 'warn' | 'danger' | 'bench' | 'neutral'; meta?: ReactNode };176const TICK_TONE: Record<NonNullable<TickerItem['tone']>, string> = { new: 'text-positive', price: 'text-accent-2', warn: 'text-warning', danger: 'text-danger', bench: 'text-type-benchmark', neutral: 'text-ink-3' };177178/** Horizontal scrolling strip of recent events (duplicated track for a seamless loop). Pausable; static + scrollable under reduced motion. */179export function Ticker({ items, className, speed = 60, label = 'Live' }: { items: TickerItem[]; className?: string; /** seconds per loop */ speed?: number; label?: string }) {180  const [paused, setPaused] = useState(false);181  const [reduced, setReduced] = useState(false);182  useEffect(() => {183    const m = window.matchMedia('(prefers-reduced-motion: reduce)');184    setReduced(m.matches);185    const on = () => setReduced(m.matches);186    m.addEventListener('change', on);187    return () => m.removeEventListener('change', on);188  }, []);189  if (!items.length) return null;190  const track = (dup: boolean) => (191    <ul className={cn('flex shrink-0 items-center gap-10 whitespace-nowrap text-xs', dup && 'aria-hidden')} aria-hidden={dup || undefined}>192      {items.map((it) => (193        <li key={`${dup ? 'd' : 'o'}-${it.id}`} className="flex items-center gap-2">194          <span className={cn('inline-block size-1.5 rounded-full bg-current', TICK_TONE[it.tone ?? 'neutral'])} aria-hidden />195          {it.href ? (196            <Link href={it.href} className="text-ink-2 hover:text-ink hover:underline" tabIndex={dup ? -1 : 0}>197              {it.label}198            </Link>199          ) : (200            <span className="text-ink-2">{it.label}</span>201          )}202          {it.meta && <span className="mono text-[10px] text-ink-3">{it.meta}</span>}203        </li>204      ))}205    </ul>206  );207  return (208    <div className={cn('flex items-center gap-3 border-y border-rule py-1.5', className)} aria-label={label} data-ticker>209      <span className="eyebrow flex shrink-0 items-center gap-1.5">210        <span className={cn('dot', !paused && !reduced && 'pulse')} aria-hidden /> {label}211      </span>212      <div className={cn('ticker min-w-0 flex-1', reduced && 'no-scrollbar overflow-x-auto')} data-paused={paused || reduced}>213        <div className={cn(reduced ? 'flex w-max gap-10' : 'ticker-track')} style={{ ['--ticker-duration' as string]: `${Math.max(20, speed * Math.max(1, items.length / 8))}s` }}>214          {track(false)}215          {!reduced && track(true)}216        </div>217      </div>218      {!reduced && (219        <button type="button" onClick={() => setPaused((p) => !p)} className="flex size-8 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-pressed={paused} aria-label={paused ? 'Resume ticker' : 'Pause ticker'}>220          {paused ? <Play className="size-3.5" aria-hidden /> : <Pause className="size-3.5" aria-hidden />}221        </button>222      )}223    </div>224  );225}226227/* --------------------------------------------------------------------------------------------------------- SectionNav */228/** Sticky in-page anchor nav; the section currently in view is highlighted (IntersectionObserver). */229export function SectionNav({ items, className }: { items: { id: string; label: string }[]; className?: string }) {230  const [activeId, setActiveId] = useState<string | null>(null);231  const seen = useRef<Map<string, boolean>>(new Map());232  useEffect(() => {233    const els = items.map((i) => document.getElementById(i.id)).filter((x): x is HTMLElement => !!x);234    if (!els.length) return;235    const io = new IntersectionObserver(236      (entries) => {237        for (const e of entries) seen.current.set(e.target.id, e.isIntersecting);238        const first = items.find((i) => seen.current.get(i.id));239        if (first) setActiveId(first.id);240      },241      { rootMargin: '-30% 0px -60% 0px', threshold: 0 },242    );243    els.forEach((el) => io.observe(el));244    return () => io.disconnect();245  }, [items]);246  return (247    <nav aria-label="On this page" className={cn('no-scrollbar sticky top-[var(--header-h)] z-30 -mx-4 overflow-x-auto border-b border-rule bg-canvas/90 px-4 backdrop-blur-md md:mx-0 md:px-0', className)} data-section-nav>248      <ul className="flex min-w-max gap-1">249        {items.map((i) => (250          <li key={i.id}>251            <a href={`#${i.id}`} aria-current={activeId === i.id ? 'location' : undefined} className={cn('flex h-10 items-center border-b-2 px-2.5 text-[13px] whitespace-nowrap', activeId === i.id ? 'border-ink text-ink font-medium' : 'border-transparent text-ink-2 hover:text-ink')}>252              {i.label}253            </a>254          </li>255        ))}256      </ul>257    </nav>258  );259}260