spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { SearchHit, SearchResponse } from "@dci/core";4import { ArrowRight, Clock, CornerDownLeft, Map as MapIcon, Search, X } from "lucide-react";5import { useRouter } from "next/navigation";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { createPortal } from "react-dom";8import { cn } from "@/components/ui/cn";9import { Kbd } from "@/components/ui/kbd";10import { apiGet, clientApiBase } from "@/lib/api";11import { fmtMw } from "@/lib/format";12import { useDebounced, useLocalStorage } from "@/lib/hooks";13import { SEARCH_GROUP_LABEL } from "@/lib/labels";14import { routes } from "@/lib/routes";1516const OPEN_EVENT = "dci:palette:open";17export function openPalette(query?: string) {18 window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: { query } }));19}2021const GROUP_ORDER: SearchHit["type"][] = ["facility", "operator", "country", "metro", "city", "cloud_region", "project", "ixp"];2223interface Row {24 kind: "hit" | "action" | "recent";25 id: string;26 title: string;27 subtitle?: string | null;28 href: string;29 group: string;30 meta?: Record<string, unknown>;31}3233export function CommandPalette() {34 const router = useRouter();35 const [open, setOpen] = useState(false);36 const [q, setQ] = useState("");37 const dq = useDebounced(q.trim(), 160);38 const [result, setResult] = useState<{ q: string; data: SearchResponse | null; error: string | null } | null>(null);39 const res = result && result.q === dq ? result.data : null;40 const error = result && result.q === dq ? result.error : null;41 const loading = open && dq.length >= 2 && result?.q !== dq;42 const [idx, setIdx] = useState(0);43 const [recent, setRecent] = useLocalStorage<string[]>("dci:recent-searches", []);44 const inputRef = useRef<HTMLInputElement>(null);45 const listRef = useRef<HTMLUListElement>(null);46 const abortRef = useRef<AbortController | null>(null);4748 useEffect(() => {49 const onOpen = (e: Event) => {50 const d = (e as CustomEvent<{ query?: string }>).detail;51 if (d?.query) setQ(d.query);52 setOpen(true);53 };54 const onKey = (e: KeyboardEvent) => {55 const target = e.target as HTMLElement | null;56 const typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);57 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {58 e.preventDefault();59 setOpen((o) => !o);60 } else if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey) {61 e.preventDefault();62 setOpen(true);63 }64 };65 window.addEventListener(OPEN_EVENT, onOpen);66 document.addEventListener("keydown", onKey);67 return () => {68 window.removeEventListener(OPEN_EVENT, onOpen);69 document.removeEventListener("keydown", onKey);70 };71 }, []);7273 useEffect(() => {74 if (!open) return;75 const prev = document.body.style.overflow;76 document.body.style.overflow = "hidden";77 setTimeout(() => inputRef.current?.focus(), 10);78 return () => {79 document.body.style.overflow = prev;80 };81 }, [open]);8283 useEffect(() => {84 if (!open || dq.length < 2) return;85 abortRef.current?.abort();86 const ctrl = new AbortController();87 abortRef.current = ctrl;88 apiGet<SearchResponse>("/search", { q: dq }, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 6000 }).then((r) => {89 if (ctrl.signal.aborted) return;90 setResult(r.ok ? { q: dq, data: r.data, error: null } : { q: dq, data: null, error: r.error });91 });92 return () => ctrl.abort();93 }, [dq, open]);9495 const rows = useMemo<Row[]>(() => {96 const out: Row[] = [];97 const term = q.trim();98 if (term.length >= 1) {99 out.push({ kind: "action", id: "a-search", title: `Search “${term}”`, subtitle: "All results", href: routes.search(term), group: "Actions" });100 out.push({ kind: "action", id: "a-map", title: `Show “${term}” on the map`, subtitle: "Filtered map", href: routes.map(`q=${encodeURIComponent(term)}`), group: "Actions" });101 }102 if (dq.length >= 2 && res?.hits?.length) {103 const sorted = [...res.hits].sort((a, b) => GROUP_ORDER.indexOf(a.type) - GROUP_ORDER.indexOf(b.type) || b.score - a.score);104 for (const h of sorted) out.push({ kind: "hit", id: `${h.type}:${h.id}`, title: h.title, subtitle: h.subtitle, href: h.href || routes.search(h.title), group: SEARCH_GROUP_LABEL[h.type] ?? h.type, meta: h.meta });105 }106 if (!term && recent.length) for (const r of recent.slice(0, 6)) out.push({ kind: "recent", id: `r-${r}`, title: r, href: routes.search(r), group: "Recent searches" });107 if (!term) {108 out.push({ kind: "action", id: "a-live", title: "Live changes", subtitle: "Latest detected updates", href: routes.live(), group: "Go to" });109 out.push({ kind: "action", id: "a-map0", title: "Global map", subtitle: "All indexed facilities", href: routes.map(), group: "Go to" });110 out.push({ kind: "action", id: "a-dc", title: "Data centers", subtitle: "Filterable index", href: routes.datacenters(), group: "Go to" });111 out.push({ kind: "action", id: "a-rank", title: "Rankings", subtitle: "Countries, markets, operators", href: routes.rankings(), group: "Go to" });112 }113 return out;114 }, [q, dq, res, recent]);115116 const rowsKey = `${rows.length}:${dq}`;117 const [prevRowsKey, setPrevRowsKey] = useState(rowsKey);118 if (prevRowsKey !== rowsKey) {119 setPrevRowsKey(rowsKey);120 setIdx(0);121 }122123 const go = useCallback(124 (row: Row) => {125 const term = q.trim();126 if (term) setRecent((p) => [term, ...p.filter((x) => x !== term)].slice(0, 8));127 setOpen(false);128 setQ("");129 router.push(row.href);130 },131 [q, router, setRecent],132 );133134 const onKeyDown = (e: React.KeyboardEvent) => {135 if (e.key === "ArrowDown") {136 e.preventDefault();137 setIdx((i) => Math.min(rows.length - 1, i + 1));138 } else if (e.key === "ArrowUp") {139 e.preventDefault();140 setIdx((i) => Math.max(0, i - 1));141 } else if (e.key === "Enter") {142 e.preventDefault();143 const r = rows[idx];144 if (r) go(r);145 } else if (e.key === "Escape") {146 e.preventDefault();147 setOpen(false);148 }149 };150151 useEffect(() => {152 const el = listRef.current?.querySelector<HTMLElement>(`[data-idx="${idx}"]`);153 el?.scrollIntoView({ block: "nearest" });154 }, [idx]);155156 if (!open || typeof document === "undefined") return null;157158 let lastGroup = "";159 return createPortal(160 <div className="fixed inset-0 z-[100] flex flex-col bg-paper sm:items-center sm:bg-ink/30 sm:px-4 sm:pt-[10vh] sm:backdrop-blur-[1px] dark:sm:bg-black/50" role="presentation" onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)}>161 <div role="dialog" aria-modal="true" aria-label="Search the index" className="flex h-full w-full flex-col bg-paper sm:h-auto sm:max-h-[70vh] sm:max-w-[640px] sm:rounded-md sm:border sm:border-hair-2 sm:bg-surface animate-fade-in">162 <div className="flex h-14 items-center gap-2 border-b border-hair px-3">163 <Search className={cn("size-[18px] shrink-0 text-ink-3", loading && "animate-pulse-soft")} strokeWidth={1.75} />164 <input165 ref={inputRef}166 value={q}167 onChange={(e) => setQ(e.target.value)}168 onKeyDown={onKeyDown}169 role="combobox"170 aria-expanded171 aria-controls="palette-list"172 aria-activedescendant={rows[idx] ? `palette-${rows[idx].id}` : undefined}173 aria-autocomplete="list"174 placeholder="Search data centers, operators, markets, countries…"175 className="h-full min-w-0 flex-1 bg-transparent text-[15px] outline-none placeholder:text-ink-4"176 autoComplete="off"177 spellCheck={false}178 />179 <button type="button" onClick={() => setOpen(false)} aria-label="Close search" className="inline-flex size-9 items-center justify-center rounded-sm text-ink-3 hover:text-ink sm:hidden">180 <X className="size-5" />181 </button>182 <Kbd className="hidden sm:inline-flex">esc</Kbd>183 </div>184 <ul ref={listRef} id="palette-list" role="listbox" className="min-h-0 flex-1 overflow-y-auto overscroll-contain py-1">185 {rows.length === 0 && (186 <li className="px-4 py-8 text-center text-[13px] text-ink-3">187 {error ? "Search is unavailable right now." : loading ? "Searching…" : dq.length >= 2 ? "No matches." : "Type to search."}188 </li>189 )}190 {rows.map((r, i) => {191 const header = r.group !== lastGroup;192 lastGroup = r.group;193 const active = i === idx;194 const mw = r.meta && typeof r.meta.mw === "number" ? fmtMw(r.meta.mw) : null;195 return (196 <li key={r.id} role="presentation">197 {header && <div className="label-caps px-4 pb-1 pt-3">{r.group}</div>}198 <button199 type="button"200 id={`palette-${r.id}`}201 role="option"202 aria-selected={active}203 data-idx={i}204 onMouseEnter={() => setIdx(i)}205 onClick={() => go(r)}206 className={cn("flex min-h-11 w-full items-center gap-3 px-4 py-1.5 text-left", active ? "bg-surface-2" : "hover:bg-surface-2/60")}207 >208 <span className="text-ink-4">{r.kind === "recent" ? <Clock className="size-4" strokeWidth={1.75} /> : r.id === "a-map" || r.id === "a-map0" ? <MapIcon className="size-4" strokeWidth={1.75} /> : r.kind === "action" ? <ArrowRight className="size-4" strokeWidth={1.75} /> : <span className="inline-block size-2 rounded-full bg-ink-3" />}</span>209 <span className="min-w-0 flex-1">210 <span className="block truncate text-[13.5px] text-ink">{r.title}</span>211 {r.subtitle && <span className="block truncate text-[11.5px] text-ink-3">{r.subtitle}</span>}212 </span>213 {mw && <span className="figure text-[12px] text-ink-3">{mw}</span>}214 {active && <CornerDownLeft className="hidden size-3.5 text-ink-4 sm:block" />}215 </button>216 </li>217 );218 })}219 </ul>220 <div className="hidden items-center gap-3 border-t border-hair px-4 py-2 text-[11px] text-ink-3 sm:flex">221 <span className="inline-flex items-center gap-1">222 <Kbd>↑</Kbd>223 <Kbd>↓</Kbd> navigate224 </span>225 <span className="inline-flex items-center gap-1">226 <Kbd>↵</Kbd> open227 </span>228 <span className="ml-auto">Tip: try “hyperscale in Ireland” or “> 100 MW under construction”</span>229 </div>230 </div>231 </div>,232 document.body,233 );234}235