SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
10.6 KB · 235 lines tsx
Raw Blame History
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 “&gt; 100 MW under construction”</span>229        </div>230      </div>231    </div>,232    document.body,233  );234}235