SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
7.3 KB · 136 lines tsx
Raw Blame History
1import type { FacilityFilters, SearchHit, SearchResponse } from "@dci/core";2import { Map as MapIcon, Search } from "lucide-react";3import type { Metadata } from "next";4import Link from "next/link";5import { FacilityTable } from "@/components/facility-table";6import { HeroSearch } from "@/components/home/hero";7import { Badge } from "@/components/ui/badge";8import { Button } from "@/components/ui/button";9import { Page, PageHeader, Section } from "@/components/ui/card";10import { EmptyState, OfflineNotice } from "@/components/ui/states";11import { api } from "@/lib/api";12import { fmtInt, fmtMw } from "@/lib/format";13import { SEARCH_GROUP_LABEL, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";14import { filtersToQuery } from "@/lib/query";15import { routes } from "@/lib/routes";1617export const revalidate = 120;1819export async function generateMetadata({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }): Promise<Metadata> {20  const sp = await searchParams;21  const q = typeof sp.q === "string" ? sp.q : "";22  return { title: q ? `“${q}” — search` : "Search", robots: { index: false, follow: true }, alternates: { canonical: "/search" } };23}2425const GROUPS: SearchHit["type"][] = ["facility", "operator", "country", "metro", "city", "cloud_region", "project", "ixp"];2627export default async function SearchPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {28  const sp = await searchParams;29  const q = (typeof sp.q === "string" ? sp.q : "").trim();3031  if (!q) {32    return (33      <Page>34        <PageHeader eyebrow="Search" title="Search the index" description="Data centers, operators, markets, countries, cloud regions, projects and internet exchanges. Natural filters work too: “hyperscale in Ireland”, “> 100 MW under construction”." />35        <HeroSearch />36      </Page>37    );38  }3940  const r = await api.search(q);41  const data: SearchResponse | null = r.ok ? r.data : null;42  const hits = data?.hits ?? [];43  const facilities = data?.facilities ?? null;44  const interp = data?.interpreted;4546  const interpFilters: FacilityFilters = {};47  if (interp?.countryIso2) interpFilters.country = interp.countryIso2;48  if (interp?.operator) interpFilters.operator = interp.operator;49  if (interp?.status) interpFilters.status = interp.status;50  if (interp?.minMw !== undefined) interpFilters.min_mw = interp.minMw;51  if (interp?.facilityType) interpFilters.type = interp.facilityType;52  if (interp?.text) interpFilters.q = interp.text;53  const mapQs = filtersToQuery(Object.keys(interpFilters).length ? interpFilters : { q });5455  const grouped = new Map<SearchHit["type"], SearchHit[]>();56  for (const h of hits.filter((h) => h.type !== "facility" || !facilities)) {57    if (!grouped.has(h.type)) grouped.set(h.type, []);58    grouped.get(h.type)!.push(h);59  }60  const nothing = r.ok && hits.length === 0 && (!facilities || facilities.total === 0);6162  return (63    <Page>64      <PageHeader65        eyebrow="Search"66        title={67          <>68            Results for <span className="text-ink-2">“{q}”</span>69          </>70        }71        description={r.ok ? `${fmtInt(hits.length + (facilities?.total ?? 0))} matches across the index.` : undefined}72        aside={73          <Button href={routes.map(mapQs)} size="sm">74            <MapIcon className="size-4" strokeWidth={1.75} /> Open in map75          </Button>76        }77      />78      {!r.ok && <OfflineNotice detail={r.error} className="mb-4" />}7980      {interp && Object.keys(interp).length > 0 && (81        <div className="mb-5 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">82          <span>Interpreted as</span>83          {interp.text && <Badge tone="outline">text: {interp.text}</Badge>}84          {interp.countryIso2 && <Badge tone="outline">country: {interp.countryIso2}</Badge>}85          {interp.operator && <Badge tone="outline">operator: {interp.operator}</Badge>}86          {interp.status && <Badge tone="outline">status: {STATUS_LABEL[interp.status]}</Badge>}87          {interp.facilityType && <Badge tone="outline">type: {TYPE_LABEL[interp.facilityType]}</Badge>}88          {interp.minMw !== undefined && <Badge tone="outline">≥ {fmtMw(interp.minMw)}</Badge>}89          <Link href={routes.datacenters(filtersToQuery(interpFilters))} className="ml-1 text-ink-2 underline decoration-hair-2 hover:text-ink">90            Apply as filters91          </Link>92        </div>93      )}9495      {nothing && <EmptyState icon={<Search className="size-5" strokeWidth={1.5} />} title={`Nothing matched “${q}”`} description="Try a shorter query, an operator name, a city or a country code." action={<Button href={routes.map()}>Browse the map</Button>} />}9697      <div className="grid gap-x-8 gap-y-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">98        {(facilities || (!r.ok && true)) && (99          <Section title="Data centers" description={facilities ? `${fmtInt(facilities.total)} facilities` : undefined} className="lg:order-1">100            <FacilityTable items={facilities?.items ?? []} basePath={routes.search(q)} columns={["operator", "country", "status", "mw", "type"]} emptyTitle={r.ok ? "No facilities match" : "Facilities unavailable"} emptyDescription={r.ok ? "Try another spelling or browse the index." : "The index API is not reachable right now."} />101            {facilities && facilities.total > facilities.items.length && (102              <div className="border-t border-hair py-3 text-[12.5px] text-ink-3">103                Showing {fmtInt(facilities.items.length)} of {fmtInt(facilities.total)} —{" "}104                <Link href={routes.datacenters(filtersToQuery(Object.keys(interpFilters).length ? interpFilters : { q }))} className="text-ink-2 underline decoration-hair-2 hover:text-ink">105                  see all in the index106                </Link>107              </div>108            )}109          </Section>110        )}111        <div className="flex flex-col gap-6 lg:order-2">112          {GROUPS.filter((g) => grouped.has(g)).map((g) => (113            <Section key={g} title={SEARCH_GROUP_LABEL[g] ?? g} level={3} aside={<span className="figure text-[11.5px] text-ink-3">{grouped.get(g)!.length}</span>}>114              <ul className="divide-y divide-hair">115                {grouped.get(g)!.map((h) => (116                  <li key={h.id}>117                    <Link href={h.href || routes.search(h.title)} className="group flex min-h-11 items-center justify-between gap-3 py-2">118                      <span className="min-w-0">119                        <span className="block truncate text-[13.5px] font-medium text-ink group-hover:text-accent">{h.title}</span>120                        {h.subtitle && <span className="block truncate text-[11.5px] text-ink-3">{h.subtitle}</span>}121                      </span>122                      {h.meta && typeof h.meta.mw === "number" && <span className="figure shrink-0 text-[12px] text-ink-3">{fmtMw(h.meta.mw)}</span>}123                      {h.meta && typeof h.meta.facilityCount === "number" && <span className="figure shrink-0 text-[12px] text-ink-3">{fmtInt(h.meta.facilityCount)} DC</span>}124                    </Link>125                  </li>126                ))}127              </ul>128            </Section>129          ))}130          {r.ok && grouped.size === 0 && !nothing && <EmptyState compact title="No entities matched" description="Only facilities matched this query." />}131        </div>132      </div>133    </Page>134  );135}136