spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import { ChevronDown, Menu, Search, X } from "lucide-react";4import Link from "next/link";5import { usePathname } from "next/navigation";6import { useEffect, useRef, useState } from "react";7import { Logo } from "@/components/brand/logo";8import { openPalette } from "@/components/command-palette";9import { ThemeToggle } from "@/components/theme";10import { IconButton } from "@/components/ui/button";11import { cn } from "@/components/ui/cn";12import { Kbd } from "@/components/ui/kbd";13import { NAV_MORE, NAV_PRIMARY, routes } from "@/lib/routes";1415/** "More" menu groups (§102) built from NAV_MORE so new routes only need lib/routes.ts. */16const MORE_GROUPS: Array<{ title: string; hrefs: string[] }> = [17 { title: "Index", hrefs: [routes.countries(), routes.cloudRegions(), routes.ixps(), routes.power(), routes.connectivity(), routes.rankings()] },18 { title: "Intelligence", hrefs: [routes.pulse(), routes.explore(), routes.compareOperators(), routes.coverage(), routes.watchlist()] },19 { title: "Reference", hrefs: [routes.sources(), routes.methodology(), routes.download(), routes.apiDocsPage(), routes.about()] },20];2122function isActive(path: string, href: string): boolean {23 if (href === "/") return path === "/";24 if (href === "/datacenters") return path.startsWith("/datacenters") && !path.startsWith("/datacenters/markets");25 return path === href || path.startsWith(`${href}/`) || path.startsWith(`${href}?`);26}2728export function Header() {29 const path = usePathname();30 const [menu, setMenu] = useState(false);31 const [more, setMore] = useState(false);32 const [menuPath, setMenuPath] = useState(path);33 const moreRef = useRef<HTMLDivElement>(null);34 if (menuPath !== path) {35 setMenuPath(path);36 setMenu(false);37 setMore(false);38 }39 useEffect(() => {40 if (!menu) return;41 const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenu(false);42 document.addEventListener("keydown", onKey);43 document.body.style.overflow = "hidden";44 return () => {45 document.removeEventListener("keydown", onKey);46 document.body.style.overflow = "";47 };48 }, [menu]);49 useEffect(() => {50 if (!more) return;51 const onDoc = (e: MouseEvent) => {52 if (!moreRef.current?.contains(e.target as Node)) setMore(false);53 };54 const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMore(false);55 document.addEventListener("mousedown", onDoc);56 document.addEventListener("keydown", onKey);57 return () => {58 document.removeEventListener("mousedown", onDoc);59 document.removeEventListener("keydown", onKey);60 };61 }, [more]);62 const isMap = path === "/map";63 const moreActive = NAV_MORE.some((n) => isActive(path, n.href));64 const byHref = new Map(NAV_MORE.map((n) => [n.href, n] as const));6566 return (67 <header className={cn("sticky top-0 z-[60] border-b border-hair bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/80", isMap && "relative")}>68 <div className="mx-auto flex h-12 w-full max-w-[1600px] items-center gap-2 px-3 sm:h-14 sm:px-5 lg:px-8">69 <Logo size={22} />70 <nav aria-label="Primary" className="ml-3 hidden items-center gap-0 lg:flex xl:ml-4 xl:gap-0.5">71 {NAV_PRIMARY.map((n) => {72 const active = isActive(path, n.href);73 return (74 <Link75 key={n.href}76 href={n.href}77 aria-current={active ? "page" : undefined}78 className={cn("relative inline-flex h-9 items-center whitespace-nowrap rounded-sm px-2 text-[13px] font-medium text-ink-2 transition-colors hover:text-ink xl:px-3", active && "text-ink after:absolute after:inset-x-2 after:-bottom-[9px] after:h-[2px] after:bg-ink sm:after:-bottom-[13px]")}79 >80 {n.label}81 {n.label === "Live" && <span aria-hidden className="ml-1.5 inline-block size-1.5 rounded-full bg-accent animate-pulse-soft" />}82 </Link>83 );84 })}85 <div ref={moreRef} className="relative">86 <button87 type="button"88 onClick={() => setMore((m) => !m)}89 aria-expanded={more}90 aria-haspopup="menu"91 aria-controls="more-menu"92 className={cn("relative inline-flex h-9 items-center gap-1 whitespace-nowrap rounded-sm px-2 text-[13px] font-medium text-ink-2 transition-colors hover:text-ink xl:px-3", (more || moreActive) && "text-ink", moreActive && "after:absolute after:inset-x-2 after:-bottom-[9px] after:h-[2px] after:bg-ink sm:after:-bottom-[13px]")}93 >94 More <ChevronDown className={cn("size-3.5 transition-transform", more && "rotate-180")} strokeWidth={1.75} aria-hidden />95 </button>96 {more && (97 <div id="more-menu" role="menu" className="absolute left-0 top-full z-[70] mt-2 grid w-[560px] grid-cols-3 gap-4 rounded-sm border border-hair-2 bg-surface p-4 animate-fade-in">98 {MORE_GROUPS.map((g) => (99 <div key={g.title}>100 <div className="label-caps mb-1.5">{g.title}</div>101 <ul className="flex flex-col">102 {g.hrefs.map((h) => {103 const n = byHref.get(h);104 if (!n) return null;105 return (106 <li key={h}>107 <Link href={h} role="menuitem" aria-current={isActive(path, h) ? "page" : undefined} className={cn("flex h-8 items-center rounded-xs px-1.5 text-[13px] text-ink-2 hover:bg-surface-2 hover:text-ink", isActive(path, h) && "font-medium text-ink")}>108 {n.label}109 </Link>110 </li>111 );112 })}113 </ul>114 </div>115 ))}116 </div>117 )}118 </div>119 </nav>120 <div className="ml-auto flex items-center gap-1">121 <button122 type="button"123 onClick={() => openPalette()}124 className="hidden h-9 items-center gap-2 rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink-3 hover:border-ink-4 hover:text-ink md:inline-flex md:w-[240px] lg:w-[190px] xl:w-[240px]"125 aria-label="Search (Command K)"126 >127 <Search className="size-4" strokeWidth={1.75} />128 <span className="flex-1 text-left">Search the index…</span>129 <span className="inline-flex gap-0.5">130 <Kbd>⌘</Kbd>131 <Kbd>K</Kbd>132 </span>133 </button>134 <IconButton label="Search" className="md:hidden" onClick={() => openPalette()}>135 <Search className="size-[18px]" strokeWidth={1.75} />136 </IconButton>137 <ThemeToggle />138 <IconButton label={menu ? "Close menu" : "Menu"} onClick={() => setMenu((m) => !m)} aria-expanded={menu} aria-controls="site-menu" className="lg:hidden">139 {menu ? <X className="size-[18px]" strokeWidth={1.75} /> : <Menu className="size-[18px]" strokeWidth={1.75} />}140 </IconButton>141 </div>142 </div>143144 {menu && (145 <div id="site-menu" className="absolute inset-x-0 top-full z-[60] max-h-[calc(100dvh-3rem)] overflow-y-auto border-b border-hair bg-paper animate-fade-in lg:hidden">146 <div className="mx-auto grid max-w-[1600px] gap-6 px-3 py-4 sm:grid-cols-2 sm:px-5">147 <div>148 <div className="label-caps mb-2">Explore</div>149 <ul className="grid grid-cols-2 gap-x-3">150 {NAV_PRIMARY.map((n) => (151 <li key={n.href}>152 <Link href={n.href} aria-current={isActive(path, n.href) ? "page" : undefined} className="flex h-11 items-center border-b border-hair text-[14px] font-medium text-ink">153 {n.label}154 </Link>155 </li>156 ))}157 </ul>158 </div>159 {MORE_GROUPS.map((g) => (160 <div key={g.title}>161 <div className="label-caps mb-2">{g.title}</div>162 <ul className="grid grid-cols-2 gap-x-3">163 {g.hrefs.map((h) => {164 const n = byHref.get(h);165 if (!n) return null;166 return (167 <li key={h}>168 <Link href={h} aria-current={isActive(path, h) ? "page" : undefined} className="flex h-11 items-center border-b border-hair text-[14px] text-ink-2">169 {n.label}170 </Link>171 </li>172 );173 })}174 </ul>175 </div>176 ))}177 </div>178 </div>179 )}180 </header>181 );182}183