SPB Git
8.3 KB · 111 lines tsx
1import type { Metadata } from "next";2import Link from "next/link";3import { searchTools, facets, type Filters } from "@/lib/public/queries";4import { parseFilters, toQuery, toggle, type SP } from "@/lib/public/params";5import { ToolCard } from "@/components/tool-card";6import { Pagination } from "@/components/pagination";7import { Breadcrumbs } from "@/components/breadcrumbs";8import { SearchBox } from "@/components/search-box";9import { label } from "@/lib/utils";10import { X, SlidersHorizontal } from "lucide-react";1112export const dynamic = "force-dynamic";13export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {14  const sp = await searchParams; const f = parseFilters(sp);15  const title = f.q ? `Search: ${f.q}` : "Directory";16  const hasFilters = Object.values(sp).some((v) => v !== undefined);17  return { title, description: "Browse and filter the full directory of tools by category, platform, license, pricing model and more.", robots: hasFilters ? { index: false, follow: true } : undefined, alternates: { canonical: "/tools" } };18}1920const GROUPS: { key: keyof Filters; title: string; facet: string }[] = [21  { key: "category", title: "Category", facet: "category" }, { key: "exec", title: "Runs", facet: "exec" }, { key: "source", title: "Source model", facet: "source" }, { key: "pricing", title: "Pricing model", facet: "pricing" },22  { key: "iface", title: "Interface", facet: "iface" }, { key: "os", title: "Operating system", facet: "os" }, { key: "status", title: "Product status", facet: "status" }, { key: "depth", title: "Editorial depth", facet: "depth" },23];2425export default async function Directory({ searchParams }: { searchParams: Promise<SP> }) {26  const f = parseFilters(await searchParams);27  const [res, fc] = await Promise.all([searchTools(f), facets(f)]);28  const active: { key: keyof Filters; value: string; text: string }[] = [];29  for (const g of GROUPS) for (const v of (f[g.key] as string[]) ?? []) active.push({ key: g.key, value: v, text: fc[g.facet]?.find((x) => x.value === v)?.label ?? label(v) });30  for (const v of f.tag ?? []) active.push({ key: "tag", value: v, text: `#${v}` });31  for (const v of f.use ?? []) active.push({ key: "use", value: v, text: label(v) });32  const base = toQuery(f, { page: 1 });33  return (34    <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6">35      <Breadcrumbs items={[{ label: "Directory" }]} />36      <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">37        <div><h1 className="display h1">{f.q ? <>Results for <span className="text-accent">“{f.q}”</span></> : "Directory"}</h1>38          <p className="mt-1 text-sm text-fg-2" aria-live="polite">{res.total} {res.total === 1 ? "tool" : "tools"}{res.mode !== "browse" && <> · {res.mode} search · {res.tookMs} ms</>}</p></div>39        <div className="lg:w-[28rem]"><SearchBox size="sm" defaultValue={f.q ?? ""} ariaLabel="Search within the directory" /></div>40      </div>4142      {active.length > 0 && (43        <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Active filters">44          {active.map((a) => <Link key={`${a.key}-${a.value}`} href={`/tools${toggle(f, a.key, a.value)}`} className="chip chip-accent hover:bg-accent/20" rel="nofollow">{a.text} <X className="h-3 w-3" aria-hidden /><span className="sr-only">remove filter</span></Link>)}45          {f.selfhost && <Link href={`/tools${toQuery(f, { selfhost: false, page: 1 })}`} className="chip chip-accent" rel="nofollow">Self-hostable <X className="h-3 w-3" aria-hidden /></Link>}46          <Link href={f.q ? `/tools?q=${encodeURIComponent(f.q)}` : "/tools"} className="text-xs text-fg-3 underline-offset-4 hover:underline" rel="nofollow">Clear all</Link>47        </div>48      )}4950      <div className="mt-6 grid gap-8 lg:grid-cols-[16rem_1fr]">51        <aside className="lg:sticky lg:top-20 lg:self-start" aria-label="Filters">52          <details className="lg:hidden rounded-xl border border-line bg-bg-2" open={active.length > 0}>53            <summary className="flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-medium"><SlidersHorizontal className="h-4 w-4" aria-hidden /> Filters {active.length ? `(${active.length})` : ""}</summary>54            <div className="px-4 pb-4"><FilterGroups f={f} fc={fc} /></div>55          </details>56          <div className="hidden lg:block"><FilterGroups f={f} fc={fc} /></div>57        </aside>58        <section aria-labelledby="results-h"><h2 id="results-h" className="sr-only">Results</h2>59          <div className="mb-4 flex flex-wrap items-center gap-2 text-sm">60            <span className="text-fg-3">Sort</span>61            {(["relevance", "name", "updated", "quality"] as const).filter((s) => s !== "relevance" || f.q).map((s) => (62              <Link key={s} href={`/tools${toQuery(f, { sort: s, page: 1 })}`} rel="nofollow" aria-current={(f.sort ?? (f.q ? "relevance" : "name")) === s ? "true" : undefined} className={`rounded-lg px-2.5 py-1 ${(f.sort ?? (f.q ? "relevance" : "name")) === s ? "bg-bg-3 font-medium text-fg" : "text-fg-2 hover:text-fg"}`}>{label(s)}</Link>63            ))}64          </div>65          {res.items.length === 0 ? (66            <div className="card p-10 text-center">67              <p className="display text-2xl">No tools match{f.q ? ` “${f.q}”` : ""}.</p>68              <p className="mt-2 text-sm text-fg-2">Try fewer filters, a broader term, or browse by <Link href="/categories" className="text-accent underline underline-offset-4">category</Link>. Missing something? <Link href="/about#corrections" className="text-accent underline underline-offset-4">Suggest a tool</Link>.</p>69            </div>70          ) : (71            <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">{res.items.map((t) => <ToolCard key={t.id} t={t} />)}</div>72          )}73          <Pagination page={res.page} pages={res.pages} makeHref={(p) => `/tools${toQuery(f, { page: p })}`} />74          <p className="sr-only">Page {res.page} of {res.pages}</p>75          <p className="mt-6 text-xs text-fg-3">{base ? <>Share this view: <code className="font-mono">/tools{base}</code></> : null}</p>76        </section>77      </div>78    </div>79  );80}8182function FilterGroups({ f, fc }: { f: Filters; fc: Record<string, { value: string; label: string; count: number }[]> }) {83  return (84    <div className="space-y-5">85      <div><Link href={`/tools${toQuery(f, { selfhost: !f.selfhost, page: 1 })}`} rel="nofollow" className={`flex items-center justify-between rounded-xl border px-3 py-2 text-sm ${f.selfhost ? "border-accent/40 bg-accent/10 text-accent" : "border-line bg-bg-2 text-fg-2 hover:bg-bg-3"}`}><span>Self-hostable only</span><span className={`h-4 w-7 rounded-full p-0.5 ${f.selfhost ? "bg-accent" : "bg-line-2"}`}><span className={`block h-3 w-3 rounded-full bg-white transition-transform ${f.selfhost ? "translate-x-3" : ""}`} /></span></Link></div>86      {GROUPS.map((g) => {87        const items = fc[g.facet] ?? []; if (!items.length) return null;88        const sel = (f[g.key] as string[]) ?? [];89        return (90          <fieldset key={g.key} className="min-w-0">91            <legend className="label">{g.title}</legend>92            <ul className="max-h-64 space-y-0.5 overflow-auto pr-1">93              {items.slice(0, g.key === "category" ? 40 : 12).map((it) => {94                const on = sel.includes(it.value);95                return (96                  <li key={it.value}>97                    <Link href={`/tools${toggle(f, g.key, it.value)}`} rel="nofollow" aria-current={on ? "true" : undefined} className={`flex items-center gap-2 rounded-lg px-2 py-1 text-sm ${on ? "bg-accent/10 text-accent" : "text-fg-2 hover:bg-bg-3 hover:text-fg"}`}>98                      <span className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${on ? "border-accent bg-accent text-accent-fg" : "border-line-2"}`} aria-hidden>{on && <svg viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 6l3 3 5-6" /></svg>}</span>99                      <span className="min-w-0 flex-1 truncate">{it.label === it.value ? label(it.value) : it.label}{on && <span className="sr-only"> (selected)</span>}</span><span className="text-xs text-fg-3">{it.count}</span>100                    </Link>101                  </li>102                );103              })}104            </ul>105          </fieldset>106        );107      })}108    </div>109  );110}111
Files · main
Menu

Theme