Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/app/benchmarks/category/[cat]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getBenchmarksSafe, toBenchmarkCardData } from "@/data/benchmarks";
import { BenchmarkGrid } from "@/components/benchmark-grid";
import { Breadcrumb } from "@/components/breadcrumb";
import { safeJsonLd, buildItemListJsonLd } from "@/lib/jsonld";
import { SITE } from "@/data/site";
import { pageMetadata } from "@/lib/page-metadata";
import { capDescription } from "@/lib/seo-text";
import { CATEGORIES, CATEGORY_BY_SLUG } from "@/lib/categories";

Check failure on line 10 in src/app/benchmarks/category/[cat]/page.tsx

View workflow job for this annotation

GitHub Actions / check

Cannot find module '@/lib/categories' or its corresponding type declarations.

/**
* Per-category hub page. One static route per entry in `CATEGORIES` so
* each architectural slice (RPCs, Bridges, Blockchains, ...) has a
* crawlable, linkable URL. Lets external sites and internal nav deep
* link directly into "all Solana RPC benchmarks" instead of relying on
* the client-only filter that lives on `/benchmarks`.
*
* Prerendered at build time via `generateStaticParams` (closed list, no
* Prom calls needed). ISR refresh aligned with the main hub so a freshly
* added bench surfaces here on the same cadence.
*/

export const revalidate = 60;

type Params = { cat: string };

export async function generateStaticParams() {
// Closed enum, generated even when a category currently has zero live
// benches (e.g. Wallets at time of writing). The page-level fallback
// below 404s empty categories so crawlers don't index thin pages.
return CATEGORIES.map((c) => ({ cat: c.slug }));

Check failure on line 32 in src/app/benchmarks/category/[cat]/page.tsx

View workflow job for this annotation

GitHub Actions / check

Parameter 'c' implicitly has an 'any' type.
}

export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { cat } = await params;
const entry = CATEGORY_BY_SLUG.get(cat);
if (!entry) return {};
const description = capDescription(entry.description, 158);
return pageMetadata({
path: `/benchmarks/category/${entry.slug}`,
title: `${entry.heading}`,
description,
});
}

export default async function BenchmarkCategoryPage({
params,
}: {
params: Promise<Params>;
}) {
const { cat } = await params;
const entry = CATEGORY_BY_SLUG.get(cat);
if (!entry) notFound();

const all = await getBenchmarksSafe();
const benchmarks = all.filter((b) => b.category === entry.label);
// Empty-category guard: a category in the enum that has no live bench
// yet returns 404 so the crawler doesn't land on a thin page. The
// category still ships in `generateStaticParams` so adding the first
// bench to it lights up the URL without a redeploy gate.
if (benchmarks.length === 0) notFound();

const url = `${SITE.url}/benchmarks/category/${entry.slug}`;
const jsonLd = buildItemListJsonLd({
name: `${entry.heading} on OpenChainBench`,
url,
description: entry.description,
items: benchmarks.map((b) => ({
name: b.title,
url: `${SITE.url}/benchmarks/${b.slug}`,
})),
breadcrumb: [
{ name: "Home", url: `${SITE.url}/` },
{ name: "All benchmarks", url: `${SITE.url}/benchmarks` },
{ name: entry.heading, url },
],
});

return (
<article className="mx-auto max-w-[1400px] px-4 sm:px-6 py-12 sm:py-16">
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
<Breadcrumb
items={[
{ label: "Benchmarks", href: "/benchmarks" },
{ label: entry.heading },
]}
/>
<header className="mb-10 mt-4">
<h1 className="display text-4xl sm:text-5xl text-ink">
{entry.heading}
</h1>
<p className="mt-4 max-w-2xl text-base sm:text-lg text-ink-soft leading-snug">
{entry.description}
</p>
</header>
<BenchmarkGrid
benchmarks={benchmarks.map(toBenchmarkCardData)}
lockedCategory={entry.label}
allCategories={Array.from(new Set(all.map((b) => b.category)))}
/>
</article>
);
}
89 changes: 69 additions & 20 deletions src/components/benchmark-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,54 @@
"use client";

import { useMemo, useState } from "react";
import Link from "next/link";
import { LayoutGrid, List, Search } from "lucide-react";
import type { BenchmarkCardData } from "@/data/benchmarks";
import { BenchmarkCard } from "@/components/benchmark-card";
import { categorySlugFromLabel } from "@/lib/categories";

Check failure on line 8 in src/components/benchmark-grid.tsx

View workflow job for this annotation

GitHub Actions / check

Cannot find module '@/lib/categories' or its corresponding type declarations.

/**
* Client-side filter/search shell for the All Benchmarks card grid.
* Hosts category pills (derived from data), a view-mode toggle (grid is
* the only fully-implemented mode here - list view degrades to a single
* column) and a search input with a ⌘K affordance.
*
* The category pills render as `<Link>` to `/benchmarks/category/<slug>`
* so crawlers see real hrefs and can follow them into the per-category
* hub pages (otherwise the category facet would only exist as
* client-only state and have no linkable URL). On click we still apply
* the in-place client filter and preventDefault so the user gets the
* snappy instant-filter UX without a navigation roundtrip.
*
* When `lockedCategory` is passed (e.g. by the `/benchmarks/category/<slug>`
* page) the grid renders pre-filtered, the search bar still works, and
* the category pills are hidden entirely so the page reads as a focused
* category hub instead of a partial filter UI.
*/
export function BenchmarkGrid({ benchmarks }: { benchmarks: BenchmarkCardData[] }) {
export function BenchmarkGrid({
benchmarks,
lockedCategory = null,
allCategories,
}: {
benchmarks: BenchmarkCardData[];
/** When set, force the grid to this category. The pill row still
* renders so users can jump to other category hubs via the same UI
* they had on the /benchmarks root. */
lockedCategory?: string | null;
/** Full category list to show in the pills, used by the category hub
* routes where the `benchmarks` prop is pre-filtered to a single
* category. If unset, the grid derives pills from `benchmarks`. */
allCategories?: string[];
}) {
const [query, setQuery] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [activeCategory, setActiveCategory] = useState<string | null>(
lockedCategory,
);
const [view, setView] = useState<"grid" | "list">("grid");
const q = query.trim().toLowerCase();

const categories = useMemo(() => {
if (allCategories && allCategories.length > 0) return allCategories;
const seen = new Set<string>();
const list: string[] = [];
for (const b of benchmarks) {
Expand All @@ -27,7 +58,7 @@
}
}
return list;
}, [benchmarks]);
}, [benchmarks, allCategories]);

const filtered = useMemo(() => {
return benchmarks.filter((b) => {
Expand All @@ -46,33 +77,51 @@
});
}, [benchmarks, q, activeCategory]);

// Pills always render. When the grid is locked to a category route,
// pill clicks navigate (no preventDefault) so the user moves between
// /benchmarks/category/<slug> URLs. On the unlocked /benchmarks page
// they filter in place for a snappier UX without a navigation
// roundtrip.
return (
<div>
{/* Filter row */}
<div className="mb-8 flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3">
<ul className="-mx-4 px-4 sm:mx-0 sm:px-0 flex flex-nowrap sm:flex-wrap overflow-x-auto sm:overflow-visible items-center gap-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<li>
<button
type="button"
<Link
href="/benchmarks"
className="pill"
data-active={activeCategory === null}
onClick={() => setActiveCategory(null)}
data-active={!lockedCategory && activeCategory === null}
onClick={(e) => {
if (lockedCategory) return;
e.preventDefault();
setActiveCategory(null);
}}
>
All
</button>
</Link>
</li>
{categories.map((c) => (
<li key={c}>
<button
type="button"
className="pill"
data-active={activeCategory === c}
onClick={() => setActiveCategory(activeCategory === c ? null : c)}
>
{c}
</button>
</li>
))}
{categories.map((c) => {
const slug = categorySlugFromLabel(c);
const href = slug ? `/benchmarks/category/${slug}` : "/benchmarks";
const isActive = lockedCategory === c || (!lockedCategory && activeCategory === c);
return (
<li key={c}>
<Link
href={href}
className="pill"
data-active={isActive}
onClick={(e) => {
if (lockedCategory) return;
e.preventDefault();
setActiveCategory(activeCategory === c ? null : c);
}}
>
{c}
</Link>
</li>
);
})}
</ul>

<div className="sm:ml-auto flex items-center gap-3">
Expand Down
Loading