Repository Public MIT
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