SPB Git

Repository Public MIT

explore.tsx 5.9 KB 115 lines tsx

1import Link from "next/link";2import { ARTIFACT_TYPES, CLIENTS, CLIENT_LABELS, TYPE_LABELS, type ArtifactType } from "@skills/registry-core";3import { plural } from "@/lib/format";4import { t } from "@/lib/i18n";5import { publicCtx } from "@/lib/session";6import { ApiError } from "@/server/errors";7import { facets, listArtifacts } from "@/server/registry";8import { ArtifactGrid, EmptyState } from "./artifact-card";910export type ExploreParams = Record<string, string | string[] | undefined>;11const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) || undefined;1213/** Search, filters and results. Filters are a plain GET form, so the page works without JavaScript. */14export async function Explore({ searchParams, fixedType, basePath, title, intro }: { searchParams: ExploreParams; fixedType?: ArtifactType; basePath: string; title: string; intro: string }) {15  const ctx = await publicCtx();16  const q = one(searchParams.q)?.slice(0, 200);17  const type = fixedType ?? one(searchParams.type);18  const compatibility = one(searchParams.compatibility);19  const license = one(searchParams.license);20  const keyword = one(searchParams.keyword);21  const sort = one(searchParams.sort);22  const page = Number(one(searchParams.page) ?? "1") || 1;2324  let result: Awaited<ReturnType<typeof listArtifacts>> | null = null;25  let error: string | null = null;26  try {27    result = await listArtifacts(ctx, { q, type, compatibility, license, keyword, sort, page, perPage: 24 });28  } catch (e) {29    if (e instanceof ApiError && e.status === 400) error = e.message;30    else throw e;31  }32  const f = await facets(ctx);33  const qs = (over: Record<string, string | undefined>) => {34    const p = new URLSearchParams();35    for (const [k, v] of Object.entries({ q, type: fixedType ? undefined : type, compatibility, license, keyword, sort, ...over })) if (v) p.set(k, v);36    const s = p.toString();37    return s ? `${basePath}?${s}` : basePath;38  };39  const filtered = !!(q || compatibility || license || keyword || (!fixedType && type));4041  return (42    <div className="container-page pb-10 pt-12">43      <header className="mb-8 max-w-3xl">44        <p className="eyebrow mb-3">Registry</p>45        <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">{title}</h1>46        <p className="mt-3 text-muted">{intro}</p>47      </header>4849      <form action={basePath} method="get" role="search" aria-label="Filter artifacts" className="card mb-8 grid gap-3 p-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,2fr)_repeat(4,minmax(0,1fr))_auto]">50        <div className="sm:col-span-2 lg:col-span-1">51          <label htmlFor="f-q" className="sr-only">Search</label>52          <input id="f-q" name="q" type="search" defaultValue={q} placeholder={t.search.placeholder} maxLength={200} className="input" />53        </div>54        {!fixedType && (55          <div>56            <label htmlFor="f-type" className="sr-only">Type</label>57            <select id="f-type" name="type" defaultValue={type ?? ""} className="input">58              <option value="">All types</option>59              {ARTIFACT_TYPES.map((x) => <option key={x} value={x}>{TYPE_LABELS[x].plural} ({f.types[x] ?? 0})</option>)}60            </select>61          </div>62        )}63        <div>64          <label htmlFor="f-client" className="sr-only">Compatible client</label>65          <select id="f-client" name="compatibility" defaultValue={compatibility ?? ""} className="input">66            <option value="">Any client</option>67            {CLIENTS.map((c) => <option key={c} value={c}>{CLIENT_LABELS[c]}</option>)}68          </select>69        </div>70        <div>71          <label htmlFor="f-license" className="sr-only">Licence</label>72          <select id="f-license" name="license" defaultValue={license ?? ""} className="input">73            <option value="">Any licence</option>74            {f.licenses.map((l) => <option key={l.license} value={l.license}>{l.license} ({l.n})</option>)}75          </select>76        </div>77        <div>78          <label htmlFor="f-sort" className="sr-only">Sort</label>79          <select id="f-sort" name="sort" defaultValue={sort ?? ""} className="input">80            <option value="">{q ? "Most relevant" : "Recently updated"}</option>81            {q && <option value="updated">Recently updated</option>}82            <option value="created">Newest</option>83            <option value="name">Name</option>84            <option value="downloads">Downloads (30 d, est.)</option>85          </select>86        </div>87        {keyword && <input type="hidden" name="keyword" value={keyword} />}88        <button type="submit" className="btn btn-primary">Apply</button>89      </form>9091      {error ? (92        <EmptyState title="This filter is not valid.">{error} <Link className="link" href={basePath}>Reset filters</Link></EmptyState>93      ) : result!.total === 0 ? (94        <EmptyState title={filtered ? t.empty.search : t.empty.registry}>95          {filtered ? <Link className="link" href={basePath}>Clear filters</Link> : <>The registry only shows real, published artifacts. <Link className="link" href="/publish">Learn how to publish</Link>.</>}96        </EmptyState>97      ) : (98        <>99          <p className="mb-4 font-mono text-xs text-faint" aria-live="polite">100            {plural(result!.total, "artifact")}{q ? ` matching “${q}”` : ""}{keyword ? ` tagged #${keyword}` : ""} · page {result!.page} of {result!.totalPages}101            {filtered && <> · <Link className="link" href={basePath}>clear</Link></>}102          </p>103          <ArtifactGrid items={result!.items} />104          {result!.totalPages > 1 && (105            <nav aria-label="Pagination" className="mt-8 flex items-center justify-between">106              {result!.page > 1 ? <Link className="btn" rel="prev" href={qs({ page: String(result!.page - 1) })}>Previous</Link> : <span />}107              {result!.page < result!.totalPages ? <Link className="btn" rel="next" href={qs({ page: String(result!.page + 1) })}>Next</Link> : <span />}108            </nav>109          )}110        </>111      )}112    </div>113  );114}115
Files main
Menu

Theme