SPB Git
5.3 KB · 57 lines tsx
1import type { Metadata } from "next";2import Link from "next/link";3import { getToolsBySlugs, type ToolDetail } from "@/lib/public/queries";4import { Breadcrumbs } from "@/components/breadcrumbs";5import { label, fmtDate } from "@/lib/utils";6import { CompareSearch } from "@/components/compare-search";7import { X } from "lucide-react";8export const dynamic = "force-dynamic";9export const metadata: Metadata = { title: "Compare tools", description: "Side-by-side comparison of two to five tools: documented facts (license, pricing model, platforms, interfaces) separated from editorial assessment.", robots: { index: false, follow: true } };1011export default async function Compare({ searchParams }: { searchParams: Promise<{ tools?: string }> }) {12  const slugs = ((await searchParams).tools ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 5);13  const tools = await getToolsBySlugs(slugs);14  const ids = tools.map((t) => t.slug);15  const facts: { k: string; v: (t: ToolDetail) => React.ReactNode }[] = [16    { k: "Vendor", v: (t) => t.vendor?.name ?? "—" }, { k: "Product type", v: (t) => label(t.productType) }, { k: "Status", v: (t) => label(t.productStatus) },17    { k: "Source model", v: (t) => label(t.sourceModel) }, { k: "License", v: (t) => t.licenseSpdx ?? (t.sourceModel === "proprietary" ? "Proprietary" : "Not verified") }, { k: "Pricing model", v: (t) => t.pricingModel.map(label).join(", ") },18    { k: "Runs", v: (t) => t.executionModes.map(label).join(", ") }, { k: "Self-hostable", v: (t) => (t.selfHostable === null ? "Unknown" : t.selfHostable ? "Yes" : "No") }, { k: "Interfaces", v: (t) => t.interfaces.map(label).join(", ") },19    { k: "Operating systems", v: (t) => t.operatingSystems.map(label).join(", ") || "—" }, { k: "Languages", v: (t) => t.languages.join(", ") || "—" }, { k: "Data formats", v: (t) => t.dataFormats.join(", ") || "—" },20    { k: "Integrations", v: (t) => t.integrations.join(", ") || "—" }, { k: "Sources cited", v: (t) => String(t.sources.length) }, { k: "Links verified", v: (t) => (t.linkState === "ok" ? fmtDate(t.checkedAt) : label(t.linkState)) },21  ];22  const editorial: { k: string; v: (t: ToolDetail) => string[] }[] = [{ k: "Capabilities", v: (t) => t.capabilities }, { k: "Strengths", v: (t) => t.advantages }, { k: "Limitations", v: (t) => t.limitations }, { k: "Best for", v: (t) => t.audiences }];23  return (24    <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6">25      <Breadcrumbs items={[{ label: "Compare" }]} />26      <h1 className="display h1 mt-3">Compare tools</h1>27      <p className="mt-2 max-w-2xl text-fg-2">Pick two to five tools. The first table lists documented facts from primary sources; the second is editorial assessment. Nothing here implies measured performance.</p>28      <div className="mt-5 flex flex-wrap items-center gap-2">29        {tools.map((t) => <Link key={t.slug} href={`/compare?tools=${ids.filter((s) => s !== t.slug).join(",")}`} rel="nofollow" className="chip chip-accent">{t.name} <X className="h-3 w-3" aria-hidden /><span className="sr-only">remove</span></Link>)}30        {tools.length < 5 && <CompareSearch current={ids} />}31      </div>32      {tools.length < 2 ? (33        <p className="card mt-8 p-8 text-center text-fg-2">Add at least two tools to compare. Tip: every tool page has a “Compare” button and lists its alternatives.</p>34      ) : (35        <>36          <h2 className="display h2 mt-10 mb-3">Documented facts</h2>37          <div className="overflow-x-auto rounded-2xl border border-line bg-bg-2" tabIndex={0} role="region" aria-label="Documented facts table">38            <table className="table min-w-[48rem]"><caption className="sr-only">Documented facts for {tools.map((t) => t.name).join(", ")}</caption>39              <thead><tr><th scope="col">Fact</th>{tools.map((t) => <th key={t.slug} scope="col"><Link href={`/tools/${t.slug}`} className="text-fg hover:text-accent normal-case tracking-normal text-sm">{t.name}</Link></th>)}</tr></thead>40              <tbody>{facts.map((f) => <tr key={f.k}><th scope="row" className="!static !bg-transparent font-medium text-fg-2 normal-case tracking-normal">{f.k}</th>{tools.map((t) => <td key={t.slug}>{f.v(t)}</td>)}</tr>)}</tbody>41            </table>42          </div>43          <h2 className="display h2 mt-10 mb-3">Editorial assessment</h2>44          <div className="overflow-x-auto rounded-2xl border border-line bg-bg-2" tabIndex={0} role="region" aria-label="Editorial assessment table">45            <table className="table min-w-[48rem]"><caption className="sr-only">Editorial assessment</caption>46              <thead><tr><th scope="col">Aspect</th>{tools.map((t) => <th key={t.slug} scope="col" className="normal-case tracking-normal text-sm text-fg">{t.name}</th>)}</tr></thead>47              <tbody>{editorial.map((e) => <tr key={e.k}><th scope="row" className="!static !bg-transparent font-medium text-fg-2 normal-case tracking-normal">{e.k}</th>{tools.map((t) => <td key={t.slug}><ul className="list-disc space-y-1 pl-4 text-fg-2">{e.v(t).map((x) => <li key={x}>{x}</li>)}</ul></td>)}</tr>)}48                <tr><th scope="row" className="!static !bg-transparent font-medium text-fg-2 normal-case tracking-normal">Summary</th>{tools.map((t) => <td key={t.slug} className="text-fg-2">{t.summary}</td>)}</tr>49              </tbody>50            </table>51          </div>52        </>53      )}54    </div>55  );56}57
Files · main
Menu

Theme