SPB Git

Repository Public

NodesExplorer.tsx 12.3 KB 197 lines tsx

1"use client";23import Link from "next/link";4import { usePathname, useSearchParams } from "next/navigation";5import { useCallback, useMemo } from "react";6import { NodeLiveInline } from "@/components/live/NodeLive";7import { facetValues, filterNodes, SORT_KEYS, sortNodes, type NodeFilters, type SortKey } from "@/lib/inventory";8import { formatDate, getDict, type Locale } from "@/lib/i18n";9import type { NodeRecord } from "@/lib/schemas";10import { chipShort, LifecycleBadge } from "./bits";1112const FILTER_KEYS = ["family", "chip", "memory", "role", "lifecycle", "rack", "q"] as const;13type Setter = (patch: Record<string, string>) => void;1415function Select({ id, label, value, set, children }: { id: string; label: string; value: string; set: Setter; children: React.ReactNode }) {16  return (17    <div className="min-w-0">18      <label htmlFor={`f-${id}`} className="label mb-1.5 block truncate">{label}</label>19      <select id={`f-${id}`} className="field !py-2.5 text-[0.9375rem]" value={value} onChange={(e) => set({ [id]: e.target.value })}>20        {children}21      </select>22    </div>23  );24}2526function SortTh({ k, label, numeric = false, sort, dir, set }: { k: SortKey; label: string; numeric?: boolean; sort: SortKey; dir: "asc" | "desc"; set: Setter }) {27  return (28    <th scope="col" aria-sort={sort === k ? (dir === "asc" ? "ascending" : "descending") : "none"} className={`whitespace-nowrap px-3 py-3 font-normal ${numeric ? "text-right" : "text-left"}`}>29      <button type="button" className="label inline-flex items-center gap-1.5 hover:text-fg" onClick={() => { const cur = new URLSearchParams(window.location.search); const same = (cur.get("sort") ?? "number") === k; set({ sort: k, dir: same && cur.get("dir") !== "desc" ? "desc" : "asc" }); }}>30        {label}31        <span aria-hidden="true" className={sort === k ? "text-accent" : "opacity-0"}>{dir === "asc" ? "↑" : "↓"}</span>32      </button>33    </th>34  );35}3637/** Filterable, sortable inventory. All state lives in the query string, so any view can be linked or bookmarked. */38export function NodesExplorer({ nodes, locale }: { nodes: NodeRecord[]; locale: Locale }) {39  const t = getDict(locale);40  const n = t.nodes;41  const pathname = usePathname();42  const params = useSearchParams();4344  const filters: NodeFilters = Object.fromEntries(FILTER_KEYS.map((k) => [k, params.get(k) ?? ""]).filter(([, v]) => v));45  const sortParam = params.get("sort") ?? "number";46  const sort: SortKey = (SORT_KEYS as readonly string[]).includes(sortParam) ? (sortParam as SortKey) : "number";47  const dir = params.get("dir") === "desc" ? "desc" : "asc";48  const view = params.get("view") === "table" ? "table" : "grid";4950  // The URL is updated with the History API: no server round trip, so filters respond instantly51  // (useSearchParams stays in sync with history.replaceState in the App Router).52  const go = useCallback(53    (qs: string) => {54      window.history.replaceState(null, "", qs ? `${pathname}?${qs}` : pathname);55    },56    [pathname],57  );58  const set = useCallback(59    (patch: Record<string, string>) => {60      const next = new URLSearchParams(window.location.search);61      for (const [k, v] of Object.entries(patch)) {62        if (v) next.set(k, v);63        else next.delete(k);64      }65      go(next.toString());66    },67    [go],68  );6970  const facets = useMemo(() => facetValues(nodes), [nodes]);71  const shown = useMemo(() => sortNodes(filterNodes(nodes, filters), sort, dir), [nodes, filters, sort, dir]);72  const hasFilter = Object.keys(filters).length > 0;73  const nd = t.common.notDocumented;7475  return (76    <div>77      <form role="search" aria-label={n.filters.heading} className="card grid grid-cols-2 gap-3 p-4 md:gap-4 md:p-6 lg:grid-cols-4" onSubmit={(e) => e.preventDefault()}>78        <div className="col-span-2 lg:col-span-1">79          <label htmlFor="f-q" className="label mb-1.5 block">{n.filters.search}</label>80          <input id="f-q" type="search" className="field !py-2.5 text-[0.9375rem]" defaultValue={filters.q ?? ""} onChange={(e) => set({ q: e.target.value })} autoComplete="off" />81        </div>82        <Select set={set} id="family" label={n.filters.family} value={filters.family ?? ""}>83          <option value="">{n.filters.any}</option>84          {facets.families.map((f) => <option key={f} value={f}>{t.common.families[f]}</option>)}85        </Select>86        <Select set={set} id="chip" label={n.filters.chip} value={filters.chip ?? ""}>87          <option value="">{n.filters.any}</option>88          {facets.chips.map((c) => <option key={c} value={c}>{chipShort(c)}</option>)}89        </Select>90        <Select set={set} id="memory" label={n.filters.memory} value={filters.memory ?? ""}>91          <option value="">{n.filters.any}</option>92          {facets.memories.map((m) => <option key={m} value={String(m)}>{n.filters.atLeast(m)}</option>)}93        </Select>94        <Select set={set} id="role" label={n.filters.role} value={filters.role ?? ""}>95          <option value="">{n.filters.any}</option>96          {facets.roles.map((r) => <option key={r} value={r}>{t.common.roles[r]}</option>)}97        </Select>98        <Select set={set} id="lifecycle" label={n.filters.lifecycle} value={filters.lifecycle ?? ""}>99          <option value="">{n.filters.any}</option>100          {facets.lifecycles.map((l) => <option key={l} value={l}>{t.common.lifecycle[l]}</option>)}101        </Select>102        <Select set={set} id="rack" label={n.filters.rack} value={filters.rack ?? ""}>103          <option value="">{n.filters.any}</option>104          {facets.racks.map((r) => <option key={r} value={r}>{n.filters.rackLabel(r)}</option>)}105          <option value="none">{n.filters.rackNone}</option>106        </Select>107        <Select set={set} id="sort" label={n.sort.label} value={sort}>108          {SORT_KEYS.map((k) => <option key={k} value={k}>{n.sort[k]}</option>)}109        </Select>110      </form>111112      <div className="mt-5 flex flex-wrap items-center justify-between gap-3">113        <p className="num font-mono text-[0.8125rem] text-body" role="status" aria-live="polite" data-testid="result-count">{n.results(shown.length, nodes.length)}</p>114        <div className="flex flex-wrap items-center gap-2">115          {hasFilter ? <button type="button" className="link mr-1 text-[0.875rem]" onClick={() => go(view === "table" ? "view=table" : "")}>{n.filters.reset}</button> : null}116          <button type="button" className="chip" onClick={() => set({ dir: dir === "asc" ? "desc" : "" })} aria-label={`${n.sort.label}: ${dir === "asc" ? n.sort.asc : n.sort.desc}`}>117            <span aria-hidden="true">{dir === "asc" ? "↑" : "↓"}</span> {dir === "asc" ? n.sort.asc : n.sort.desc}118          </button>119          <div role="group" aria-label={n.view.label} className="flex gap-1.5">120            {(["grid", "table"] as const).map((v) => (121              <button key={v} type="button" aria-pressed={view === v} onClick={() => set({ view: v === "grid" ? "" : "table" })} className="chip">122                {n.view[v]}123              </button>124            ))}125          </div>126        </div>127      </div>128129      {shown.length === 0 ? (130        <p className="card mt-5 p-10 text-center text-body">{n.empty}</p>131      ) : view === "grid" ? (132        <ul className="mt-5 grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4" data-testid="node-grid">133          {shown.map((node) => (134            <li key={node.id}>135              <Link href={`/${locale}/nodes/${node.id}`} className="card card-hover group flex h-full flex-col p-3 sm:p-4 md:p-5">136                <div className="flex flex-wrap items-center justify-between gap-2">137                  <span className="num font-mono text-[0.6875rem] tracking-[0.1em] text-mute">{String(node.number).padStart(2, "0")}</span>138                  <LifecycleBadge state={node.lifecycle} label={t.common.lifecycle[node.lifecycle]} />139                </div>140                <h2 className="mt-3 text-[1.0625rem] font-semibold sm:mt-4 sm:text-[1.3125rem] tracking-[-0.025em] group-hover:text-accent">{node.publicName}</h2>141                <p className="mt-0.5 text-[0.8125rem] text-body sm:text-[0.875rem]">{t.common.families[node.family]} · {node.chip ? chipShort(node.chip) : nd}</p>142                <p className="num mt-2 font-mono text-[0.75rem] text-mute sm:hidden">{node.unifiedMemoryGB !== null ? `${node.unifiedMemoryGB} ${t.common.gb}` : "—"} · {node.cpuCores ?? "—"} {t.common.cores}</p>143                <dl className="num mt-4 hidden grid-cols-3 gap-2 rounded-xl bg-surface-2 p-3 font-mono text-[0.75rem] sm:grid">144                  <div><dt className="text-mute">{n.cols.memory}</dt><dd className="mt-0.5 text-fg">{node.unifiedMemoryGB !== null ? `${node.unifiedMemoryGB} ${t.common.gb}` : "—"}</dd></div>145                  <div><dt className="text-mute">{n.cols.cpu}</dt><dd className="mt-0.5 text-fg">{node.cpuCores ?? "—"}</dd></div>146                  <div><dt className="text-mute">{n.cols.gpu}</dt><dd className="mt-0.5 text-fg">{node.gpuCores ?? "—"}</dd></div>147                </dl>148                <p className="mt-3 hidden text-[0.8125rem] text-mute sm:block">{node.role.map((r) => t.common.roles[r]).join(" · ")}</p>149                <p className="mt-auto min-h-[1.75rem] pt-3"><NodeLiveInline id={node.id} locale={locale} /></p>150              </Link>151            </li>152          ))}153        </ul>154      ) : (155        <div className="card mt-5 overflow-x-auto" tabIndex={0} role="region" aria-label={n.title}>156          <table className="num w-full min-w-[64rem] border-collapse text-[0.875rem]" data-testid="node-table">157            <caption className="sr-only">{n.title}</caption>158            <thead className="border-b border-line bg-surface-2">159              <tr>160                <SortTh sort={sort} dir={dir} set={set} k="number" label="#" />161                <SortTh sort={sort} dir={dir} set={set} k="name" label={n.cols.node} />162                <SortTh sort={sort} dir={dir} set={set} k="family" label={n.cols.family} />163                <SortTh sort={sort} dir={dir} set={set} k="chip" label={n.cols.chip} />164                <SortTh sort={sort} dir={dir} set={set} k="cpu" label={n.cols.cpu} numeric />165                <SortTh sort={sort} dir={dir} set={set} k="gpu" label={n.cols.gpu} numeric />166                <SortTh sort={sort} dir={dir} set={set} k="memory" label={n.cols.memory} numeric />167                <SortTh sort={sort} dir={dir} set={set} k="storage" label={n.cols.storage} numeric />168                <th scope="col" className="label px-3 py-3 text-left font-normal">{n.cols.role}</th>169                <th scope="col" className="label px-3 py-3 text-left font-normal">{n.cols.status}</th>170                <th scope="col" className="label px-3 py-3 text-left font-normal">{n.cols.verified}</th>171              </tr>172            </thead>173            <tbody>174              {shown.map((node) => (175                <tr key={node.id} className="border-b border-line last:border-0 hover:bg-bg">176                  <td className="px-3 py-3 font-mono text-mute">{String(node.number).padStart(2, "0")}</td>177                  <th scope="row" className="px-3 py-3 text-left font-medium"><Link className="link" href={`/${locale}/nodes/${node.id}`}>{node.publicName}</Link></th>178                  <td className="px-3 py-3 text-body">{t.common.families[node.family]}</td>179                  <td className="px-3 py-3 text-body">{node.chip ? chipShort(node.chip) : nd}</td>180                  <td className="px-3 py-3 text-right font-mono">{node.cpuCores ?? "—"}</td>181                  <td className="px-3 py-3 text-right font-mono">{node.gpuCores ?? <span title={nd}>—</span>}</td>182                  <td className="px-3 py-3 text-right font-mono">{node.unifiedMemoryGB !== null ? `${node.unifiedMemoryGB} ${t.common.gb}` : "—"}</td>183                  <td className="px-3 py-3 text-right font-mono">{node.storageGB !== null ? `${node.storageGB} ${t.common.gb}` : "—"}</td>184                  <td className="px-3 py-3 text-body">{node.role.map((r) => t.common.roles[r]).join(", ")}</td>185                  <td className="px-3 py-3"><LifecycleBadge state={node.lifecycle} label={t.common.lifecycle[node.lifecycle]} /></td>186                  <td className="whitespace-nowrap px-3 py-3 text-mute">{node.verifiedAt ? <time dateTime={node.verifiedAt}>{formatDate(node.verifiedAt, locale)}</time> : nd}</td>187                </tr>188              ))}189            </tbody>190          </table>191        </div>192      )}193      <p className="mt-5 max-w-[70ch] text-[0.8125rem] leading-relaxed text-mute">{n.statusNote}</p>194    </div>195  );196}197
Files main
Menu

Theme