spb/tools-spboucher-ai
Public
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