SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
6.0 KB · 133 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { usePathname } from "next/navigation";5import { Activity, Bell, Bookmark, Compass, Eye, Globe2, LayoutGrid, Radar, Search, Siren, X, Zap } from "lucide-react";6import { useState } from "react";7import { Mark } from "./brand";8import { DensityToggle } from "./prefs";9import { ThemeToggle } from "./theme";10import { Kbd } from "./ui";1112const NAV = [13  ["/live", "Live"],14  ["/breaking", "Breaking"],15  ["/pulse", "Pulse"],16  ["/radar", "Radar"],17  ["/silent", "Silent"],18  ["/explore", "Explore"],19  ["/entities", "Entities"],20  ["/sources", "Sources"],21  ["/coverage", "Coverage"],22  ["/watchlists", "Watchlists"],23  ["/alerts", "Alerts"],24] as const;2526export function Wordmark() {27  return (28    <Link href="/" className="flex items-center gap-2 font-semibold tracking-tight" aria-label="WebSensor home">29      <Mark size={22} className="shrink-0 rounded-[5px]" />30      <span className="text-[15px]">31        Web<span className="text-signal">Sensor</span>32      </span>33    </Link>34  );35}3637export function TopNav() {38  const path = usePathname();39  const openPalette = (): void => {40    window.dispatchEvent(new Event("ws:palette"));41  };42  return (43    <header className="sticky top-0 z-40 border-b border-line bg-bg/90 backdrop-blur">44      <div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3 sm:px-4">45        <Wordmark />46        <nav className="hidden items-center gap-0.5 lg:flex" aria-label="Primary">47          {NAV.map(([href, label]) => {48            const active = href === "/live" ? path === "/" || path.startsWith("/live") : path.startsWith(href);49            return (50              <Link key={href} href={href} aria-current={active ? "page" : undefined} className={`rounded-md px-1.5 py-1 text-[12px] xl:px-2 xl:text-[12.5px] ${active ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"} ${href === "/breaking" && active ? "!text-hot" : href === "/silent" && active ? "!text-silent" : ""}`}>51                {label}52              </Link>53            );54          })}55        </nav>56        <button type="button" onClick={openPalette} className="ml-auto inline-flex h-8 w-40 min-w-0 items-center gap-2 rounded-md border border-line bg-panel px-2 text-[12.5px] text-fg-subtle hover:border-line-strong sm:w-56 lg:w-48 xl:w-72" aria-label="Open search and commands (⌘K)">57          <Search className="size-3.5 shrink-0" />58          <span className="flex-1 truncate text-left">Search entities, events, URLs…</span>59          <span className="hidden sm:inline-flex"><Kbd>⌘K</Kbd></span>60        </button>61        <div className="hidden xl:block"><DensityToggle compact /></div>62        <ThemeToggle />63      </div>64    </header>65  );66}6768/** Mobile bottom navigation (spec §95): Live · Breaking · Explore · Watchlists · More (bottom sheet). */69export function MobileNav() {70  const path = usePathname();71  const [moreFor, setMoreFor] = useState<string | null>(null);72  const more = moreFor === path;73  const setMore = (v: boolean): void => setMoreFor(v ? path : null);74  const items = [75    ["/live", "Live", Activity, path === "/" || path.startsWith("/live")],76    ["/breaking", "Breaking", Siren, path.startsWith("/breaking")],77    ["/explore", "Explore", Compass, path.startsWith("/explore")],78    ["/watchlists", "Watch", Eye, path.startsWith("/watchlists")],79  ] as const;80  const moreItems: [string, string, typeof Activity][] = [81    ["/pulse", "Pulse", Zap],82    ["/radar", "Radar", Radar],83    ["/silent", "Silent changes", LayoutGrid],84    ["/entities", "Entities", Globe2],85    ["/sources", "Sources", Globe2],86    ["/coverage", "Coverage", Globe2],87    ["/country", "Countries", Globe2],88    ["/alerts", "Alerts", Bell],89    ["/bookmarks", "Bookmarks", Bookmark],90    ["/health", "Health", Activity],91    ["/api", "API", Zap],92  ];93  return (94    <>95      <nav className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-5 border-t border-line bg-bg/95 backdrop-blur lg:hidden" style={{ paddingBottom: "env(safe-area-inset-bottom)" }} aria-label="Mobile">96        {items.map(([href, label, Icon, active]) => (97          <Link key={href} href={href} aria-current={active ? "page" : undefined} className={`flex flex-col items-center gap-0.5 py-2 text-[10.5px] ${active ? (href === "/breaking" ? "text-hot" : "text-signal") : "text-fg-muted"}`}>98            <Icon className="size-4" />99            {label}100          </Link>101        ))}102        <button type="button" onClick={() => setMore(true)} aria-expanded={more} className={`flex flex-col items-center gap-0.5 py-2 text-[10.5px] ${more ? "text-signal" : "text-fg-muted"}`}>103          <LayoutGrid className="size-4" />104          More105        </button>106      </nav>107      {more && (108        <div className="fixed inset-0 z-50 flex items-end lg:hidden" role="dialog" aria-modal="true" aria-label="More">109          <button type="button" aria-label="Close" onClick={() => setMore(false)} className="scrim absolute inset-0 cursor-default" />110          <div className="relative w-full rounded-t-xl border-t border-line bg-panel pb-6 animate-sheet-up" style={{ paddingBottom: "calc(1.5rem + env(safe-area-inset-bottom))" }}>111            <div className="flex items-center justify-between px-4 py-3">112              <span className="label">More</span>113              <span className="flex items-center gap-2">114                <DensityToggle />115                <button type="button" onClick={() => setMore(false)} aria-label="Close" className="inline-flex size-7 items-center justify-center rounded-md border border-line"><X className="size-4" /></button>116              </span>117            </div>118            <ul className="grid grid-cols-2 gap-1 px-3">119              {moreItems.map(([href, label, Icon]) => (120                <li key={href}>121                  <Link href={href} className="flex items-center gap-2 rounded-md border border-line px-3 py-2.5 text-[13px] hover:bg-panel-2">122                    <Icon className="size-4 text-fg-muted" /> {label}123                  </Link>124                </li>125              ))}126            </ul>127          </div>128        </div>129      )}130    </>131  );132}133