TypeScript 55.4%
Python 43.2%
SQL 1.2%
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