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%
10.2 KB · 205 lines tsx
Raw Blame History
1"use client";23import type { DownloadDataset } from "@dci/core";4import { EVENT_TYPES, FACILITY_STATUSES, FACILITY_TYPES, PIPELINE_STATUSES } from "@dci/core";5import { Check, Copy, Download } from "lucide-react";6import { useMemo, useState } from "react";7import { Button } from "@/components/ui/button";8import { cn } from "@/components/ui/cn";9import { SegmentedControl } from "@/components/ui/segmented";10import { clientApiBase } from "@/lib/api";11import { EVENT_LABEL, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";1213const inputCls = "h-10 w-full min-w-0 rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink outline-none focus:border-ink sm:h-9";14const FORMAT_LABEL: Record<DownloadDataset["formats"][number], string> = { csv: "CSV", json: "JSON", geojson: "GeoJSON" };1516type Values = Record<string, string | boolean>;1718function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {19  return (20    <label className={cn("block min-w-0", className)}>21      <span className="label-caps mb-1 block">{label}</span>22      {children}23    </label>24  );25}2627/** Builds the /api/v1/download/:key URL from the dataset's own filter list; nothing else is offered. */28export function DatasetForm({ dataset }: { dataset: DownloadDataset }) {29  const [format, setFormat] = useState<DownloadDataset["formats"][number]>(dataset.formats[0] ?? "csv");30  const [values, setValues] = useState<Values>({});31  const [copied, setCopied] = useState(false);32  const base = useMemo(() => (process.env.NEXT_PUBLIC_API_URL ? `${process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, "")}/api/v1` : clientApiBase()), []);3334  const url = useMemo(() => {35    const sp = new URLSearchParams();36    if (format !== "csv") sp.set("format", format);37    for (const f of dataset.filters) {38      const v = values[f];39      if (v === undefined || v === "" || v === false) continue;40      sp.set(f, v === true ? "1" : String(v));41    }42    const qs = sp.toString();43    return `${base}/download/${dataset.key}${qs ? `?${qs}` : ""}`;44  }, [base, dataset, format, values]);4546  const set = (k: string, v: string | boolean) => setValues((p) => ({ ...p, [k]: v }));47  const copy = async () => {48    try {49      await navigator.clipboard.writeText(url);50      setCopied(true);51      setTimeout(() => setCopied(false), 1800);52    } catch {53      window.prompt("Copy the download URL", url);54    }55  };56  const has = (f: string) => dataset.filters.includes(f);5758  return (59    <div className="flex flex-col gap-3">60      {dataset.filters.length > 0 ? (61        <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">62          {has("country") && (63            <Field label="Country (ISO2, comma list)">64              <input value={(values.country as string) ?? ""} onChange={(e) => set("country", e.target.value.toUpperCase().replace(/[^A-Z,]/g, ""))} placeholder="US,CA" className={cn(inputCls, "figure")} />65            </Field>66          )}67          {has("status") && (68            <Field label="Status">69              <select value={(values.status as string) ?? ""} onChange={(e) => set("status", e.target.value)} className={inputCls}>70                <option value="">Any status</option>71                {FACILITY_STATUSES.map((s) => (72                  <option key={s} value={s}>73                    {STATUS_LABEL[s]}74                  </option>75                ))}76              </select>77            </Field>78          )}79          {has("project_status") && (80            <Field label="Project status">81              <select value={(values.project_status as string) ?? ""} onChange={(e) => set("project_status", e.target.value)} className={inputCls}>82                <option value="">Any status</option>83                {[...PIPELINE_STATUSES, "operational", "expansion", "cancelled"].map((s) => (84                  <option key={s} value={s}>85                    {STATUS_LABEL[s as keyof typeof STATUS_LABEL]}86                  </option>87                ))}88              </select>89            </Field>90          )}91          {has("type") && (92            <Field label="Facility type">93              <select value={(values.type as string) ?? ""} onChange={(e) => set("type", e.target.value)} className={inputCls}>94                <option value="">Any type</option>95                {FACILITY_TYPES.map((t) => (96                  <option key={t} value={t}>97                    {TYPE_LABEL[t]}98                  </option>99                ))}100              </select>101            </Field>102          )}103          {has("event_type") && (104            <Field label="Event type">105              <select value={(values.event_type as string) ?? ""} onChange={(e) => set("event_type", e.target.value)} className={inputCls}>106                <option value="">Any type</option>107                {EVENT_TYPES.map((t) => (108                  <option key={t} value={t}>109                    {EVENT_LABEL[t]}110                  </option>111                ))}112              </select>113            </Field>114          )}115          {has("operator") && (116            <Field label="Operator slug">117              <input value={(values.operator as string) ?? ""} onChange={(e) => set("operator", e.target.value.trim())} placeholder="equinix" className={cn(inputCls, "figure")} />118            </Field>119          )}120          {has("metro") && (121            <Field label="Market slug">122              <input value={(values.metro as string) ?? ""} onChange={(e) => set("metro", e.target.value.trim())} placeholder="northern-virginia" className={cn(inputCls, "figure")} />123            </Field>124          )}125          {has("min_mw") && (126            <Field label="Min MW">127              <input type="number" min={0} inputMode="decimal" value={(values.min_mw as string) ?? ""} onChange={(e) => set("min_mw", e.target.value)} className={cn(inputCls, "figure")} />128            </Field>129          )}130          {has("max_mw") && (131            <Field label="Max MW">132              <input type="number" min={0} inputMode="decimal" value={(values.max_mw as string) ?? ""} onChange={(e) => set("max_mw", e.target.value)} className={cn(inputCls, "figure")} />133            </Field>134          )}135          {has("expected_from") && (136            <Field label="Expected from (year)">137              <input type="number" min={2000} max={2100} inputMode="numeric" value={(values.expected_from as string) ?? ""} onChange={(e) => set("expected_from", e.target.value)} className={cn(inputCls, "figure")} />138            </Field>139          )}140          {has("expected_to") && (141            <Field label="Expected to (year)">142              <input type="number" min={2000} max={2100} inputMode="numeric" value={(values.expected_to as string) ?? ""} onChange={(e) => set("expected_to", e.target.value)} className={cn(inputCls, "figure")} />143            </Field>144          )}145          {has("since") && (146            <Field label="Since (date)">147              <input type="date" value={(values.since as string) ?? ""} onChange={(e) => set("since", e.target.value)} className={cn(inputCls, "figure")} />148            </Field>149          )}150          {has("until") && (151            <Field label="Until (date)">152              <input type="date" value={(values.until as string) ?? ""} onChange={(e) => set("until", e.target.value)} className={cn(inputCls, "figure")} />153            </Field>154          )}155          {has("min_significance") && (156            <Field label="Min significance (0–100)">157              <input type="number" min={0} max={100} inputMode="numeric" value={(values.min_significance as string) ?? ""} onChange={(e) => set("min_significance", e.target.value)} className={cn(inputCls, "figure")} />158            </Field>159          )}160          {(has("ai") || has("hyperscale") || has("has_mw")) && (161            <div className="flex flex-wrap items-end gap-3 sm:col-span-2 lg:col-span-3">162              {has("ai") && (163                <label className="inline-flex min-h-10 items-center gap-2 text-[13px] text-ink-2">164                  <input type="checkbox" checked={values.ai === true} onChange={(e) => set("ai", e.target.checked)} className="size-5 accent-[var(--accent)]" /> AI / HPC only165                </label>166              )}167              {has("hyperscale") && (168                <label className="inline-flex min-h-10 items-center gap-2 text-[13px] text-ink-2">169                  <input type="checkbox" checked={values.hyperscale === true} onChange={(e) => set("hyperscale", e.target.checked)} className="size-5 accent-[var(--accent)]" /> Hyperscale only170                </label>171              )}172              {has("has_mw") && (173                <label className="inline-flex min-h-10 items-center gap-2 text-[13px] text-ink-2">174                  <input type="checkbox" checked={values.has_mw === true} onChange={(e) => set("has_mw", e.target.checked)} className="size-5 accent-[var(--accent)]" /> Known MW only175                </label>176              )}177            </div>178          )}179        </div>180      ) : (181        <p className="text-[12.5px] text-ink-3">This dataset has no filters — it is downloaded whole.</p>182      )}183184      <div className="flex flex-wrap items-center gap-2">185        <SegmentedControl label="Format" value={format} onChange={(v) => setFormat(v)} options={dataset.formats.map((f) => ({ value: f, label: FORMAT_LABEL[f] }))} />186        <span className="text-[11.5px] text-ink-4">{format === "geojson" ? "FeatureCollection; rows without coordinates are omitted." : format === "json" ? "{ data: rows[], meta } — meta lists excluded sources." : "UTF-8, header row, one record per line."}</span>187      </div>188189      <div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">190        <div className="figure min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-sm border border-hair bg-surface-2 px-2.5 py-2 text-[12px] text-ink-2" aria-label="Download URL">191          {url}192        </div>193        <div className="flex gap-2">194          <Button size="sm" variant="secondary" onClick={() => void copy()} aria-label="Copy download URL">195            {copied ? <Check className="size-3.5 text-ok" aria-hidden /> : <Copy className="size-3.5" aria-hidden />} {copied ? "Copied" : "Copy"}196          </Button>197          <Button size="sm" variant="primary" href={url} external>198            <Download className="size-3.5" aria-hidden /> Download {FORMAT_LABEL[format]}199          </Button>200        </div>201      </div>202    </div>203  );204}205