From 1cb0c15c1d4e810a7fbf473d9296bd0433ffeb52 Mon Sep 17 00:00:00 2001
From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:42:21 +0200
Subject: [PATCH 1/5] share-card modal: scope dropdowns for
chain/region/venue/kind
---
src/components/share-section.tsx | 241 ++++++++++++++++++++++++++++---
1 file changed, 220 insertions(+), 21 deletions(-)
diff --git a/src/components/share-section.tsx b/src/components/share-section.tsx
index d7131d56..4c3fc535 100644
--- a/src/components/share-section.tsx
+++ b/src/components/share-section.tsx
@@ -5,6 +5,24 @@ import { Download, Image as ImageIcon, Loader2, X } from "lucide-react";
import type { Benchmark } from "@/types/benchmark";
import { Hint } from "@/components/hint";
+/** Dimensions the share-card handler on the server understands. Kept in
+ * sync with the pickOption() dims in
+ * src/app/benchmarks/[slug]/share-card/route.tsx. */
+const SCOPE_DIMS = ["chain", "region", "venue", "kind"] as const;
+type ScopeDim = (typeof SCOPE_DIMS)[number];
+
+/** Human-readable label for each dimension dropdown. */
+const DIM_LABEL: Record = {
+ chain: "Chain",
+ region: "Region",
+ venue: "Venue",
+ kind: "Kind",
+};
+
+/** Sentinel value the dropdowns use for "no filter on this dimension".
+ * Same as the bench detail page and the share-card server handler. */
+const ALL_VALUE = "all";
+
type Template = {
id: string;
label: string;
@@ -59,6 +77,64 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
const [activeId, setActiveId] = useState("ranking");
const [open, setOpen] = useState(false);
+ // Which dimensions this bench actually declares. Only those get a
+ // dropdown in the modal; the rest are hidden. Kept as a plain object
+ // so template rendering can `dimOptions.venue?.length` without a
+ // per-render `.filter().find()` pass.
+ const dimOptions = useMemo(() => {
+ const out: Partial> = {};
+ for (const dim of SCOPE_DIMS) {
+ const opts = benchmark.dimensions?.[dim] ?? [];
+ // Drop the synthetic "all" option if the spec includes it: the
+ // modal always prepends its own "All" entry as the default so
+ // duplicating breaks the value equality check on select onChange.
+ const filtered = opts.filter((o) => o.value !== ALL_VALUE);
+ if (filtered.length > 0) out[dim] = filtered;
+ }
+ return out;
+ }, [benchmark]);
+ const hasAnyDim = Object.keys(dimOptions).length > 0;
+
+ // Scope state: one entry per SCOPE_DIMS. Default is "all" per dim.
+ // Initialised once from window.location when the modal opens, so the
+ // dropdowns start on whatever the user was filtering on the dashboard
+ // (not surprising) but can be changed freely from inside the modal.
+ const [scope, setScope] = useState>({
+ chain: chain ?? ALL_VALUE,
+ region: ALL_VALUE,
+ venue: ALL_VALUE,
+ kind: ALL_VALUE,
+ });
+ // Re-sync scope from URL every time the modal opens: reader might
+ // have changed a chain / region tab on the dashboard between clicks,
+ // and expecting a fresh open to reflect that is less surprising than
+ // stale sticky state.
+ useEffect(() => {
+ if (!open) return;
+ if (typeof window === "undefined") return;
+ const url = new URL(window.location.href);
+ const next: Record = {
+ chain: url.searchParams.get("chain") || ALL_VALUE,
+ region: url.searchParams.get("region") || ALL_VALUE,
+ venue: url.searchParams.get("venue") || ALL_VALUE,
+ kind: url.searchParams.get("kind") || ALL_VALUE,
+ };
+ // Coerce values the current bench does not offer back to "all". A
+ // chain the reader was previously filtering by can vanish between
+ // deploys (spec edit); silently falling back beats a broken picker.
+ for (const dim of SCOPE_DIMS) {
+ const opts = dimOptions[dim];
+ if (
+ opts &&
+ next[dim] !== ALL_VALUE &&
+ !opts.some((o) => o.value === next[dim])
+ ) {
+ next[dim] = ALL_VALUE;
+ }
+ }
+ setScope(next);
+ }, [open, dimOptions]);
+
// Lock body scroll while modal open and close on Escape.
useEffect(() => {
if (!open) return;
@@ -123,28 +199,19 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
setPairB(s);
}
- // Build the URL with the right params per template.
+ // Build the URL with the right params per template. Scope state is
+ // the source of truth: the modal dropdowns own it, so the preview
+ // and download always agree with what the user picked inside the
+ // modal (not what happened to be in window.location at open time).
const cardSrc = (templateId: string) => {
const tpl = TEMPLATES.find((t) => t.id === templateId);
- // Read every dimension filter from the live URL so the share-card
- // stays in sync when the user flips a chain / region / kind / venue
- // tab client-side.
- const liveUrl =
- typeof window !== "undefined"
- ? new URL(window.location.href)
- : null;
- const liveChain = liveUrl
- ? liveUrl.searchParams.get("chain")
- : chain ?? null;
- const chainParam = liveChain ? `&chain=${encodeURIComponent(liveChain)}` : "";
- const liveRegion = liveUrl ? liveUrl.searchParams.get("region") : null;
- const regionParam = liveRegion
- ? `®ion=${encodeURIComponent(liveRegion)}`
- : "";
- const liveKind = liveUrl ? liveUrl.searchParams.get("kind") : null;
- const kindParam = liveKind ? `&kind=${encodeURIComponent(liveKind)}` : "";
- const liveVenue = liveUrl ? liveUrl.searchParams.get("venue") : null;
- const venueParam = liveVenue ? `&venue=${encodeURIComponent(liveVenue)}` : "";
+ const dimParams: string[] = [];
+ for (const dim of SCOPE_DIMS) {
+ const val = scope[dim];
+ if (val && val !== ALL_VALUE) {
+ dimParams.push(`&${dim}=${encodeURIComponent(val)}`);
+ }
+ }
// Mirror the active site theme so the exported PNG matches what the
// user is looking at. SSR can't read the dark state - default to light
// server-side, the client re-renders with `dark` once mounted.
@@ -152,7 +219,7 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
typeof window !== "undefined" &&
document.documentElement.classList.contains("dark");
const themeParam = isDark ? "&theme=dark" : "";
- const base = `/benchmarks/${slug}/share-card?template=${templateId}${chainParam}${regionParam}${kindParam}${venueParam}${themeParam}`;
+ const base = `/benchmarks/${slug}/share-card?template=${templateId}${dimParams.join("")}${themeParam}`;
if (!tpl) return base;
if (tpl.pick === "multi") {
if (
@@ -232,6 +299,25 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
Pick a layout and download a 1200×630 PNG ready for Twitter, Reddit, LinkedIn or any OG-card embed. Same data, same colors as this dashboard.