From 4d3330a374b63e3d98438c2d5e51b76711cd6d03 Mon Sep 17 00:00:00 2001 From: frapercan Date: Fri, 8 May 2026 18:18:56 +0200 Subject: [PATCH] feat(web): URL-sync filters + sticky table headers + scroll shadows Three small but high-frequency wins for data-table UX. No logic changes. URL-sync filters (apps/web/lib/useUrlParam.ts) - New ``useUrlParam(key, default)`` and ``useUrlNumber`` hooks: two-way binding between a query-string key and React state. Uses ``router.replace`` with ``scroll: false`` so chip clicks don't scroll the page or pollute the back-button stack. Defaults are dropped from the URL (clean copy/paste). - Applied to: /benchmark stage, k, eval_set /jobs status /proteins tab - Refresh / share-link now lands on the same view. Sticky data-table headers (.protea-thead-sticky in globals.css) - New utility: ``position: sticky; top: 4rem`` (clears the h-16 chrome header) + white background + 1px shadow. - Applied to /benchmark matrix table, /proteins browse grid header, /embeddings configs grid header. Scroll-shadow indicator (.protea-scroll-shadow) - Roman Komarov's local/scroll background-attachment trick: faint shadows at the left/right edges of horizontally scrollable containers, masked by white covers anchored to the content. Tells the user "more content this way" without a separate JS observer. - Applied to the same wrappers above. CI: next build green; backend untouched. --- apps/web/app/[locale]/benchmark/page.tsx | 19 ++++--- apps/web/app/[locale]/embeddings/page.tsx | 4 +- apps/web/app/[locale]/jobs/page.tsx | 5 +- apps/web/app/[locale]/proteins/page.tsx | 13 ++++- apps/web/app/globals.css | 28 ++++++++++ apps/web/lib/useUrlParam.ts | 67 +++++++++++++++++++++++ 6 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 apps/web/lib/useUrlParam.ts diff --git a/apps/web/app/[locale]/benchmark/page.tsx b/apps/web/app/[locale]/benchmark/page.tsx index 5c33b92..b7f4aa9 100644 --- a/apps/web/app/[locale]/benchmark/page.tsx +++ b/apps/web/app/[locale]/benchmark/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Skeleton } from "@/components/Skeleton"; +import { useUrlNumber, useUrlParam } from "@/lib/useUrlParam"; import { getBenchmarkEmbeddings, getBenchmarkMatrix, @@ -143,9 +144,12 @@ export default function BenchmarkPage() { const [embeddings, setEmbeddings] = useState(null); const [matrix, setMatrix] = useState(null); const [error, setError] = useState(null); - const [stage, setStage] = useState(null); - const [evalSetId, setEvalSetId] = useState("all"); - const [selectedK, setSelectedK] = useState(null); + // URL-synced filters: copy the page link and the chips persist. + const [stage, setStage] = useUrlParam("stage", null); + const [evalSetIdRaw, setEvalSetIdRaw] = useUrlParam("eval_set", "all"); + const evalSetId = (evalSetIdRaw ?? "all") as string | "all"; + const setEvalSetId = (v: string | "all") => setEvalSetIdRaw(v === "all" ? null : v); + const [selectedK, setSelectedK] = useUrlNumber("k", null); // Unfiltered catalog fetch — populates the full set of known stages and // eval sets, so selector chips don't disappear when a filtered query @@ -168,8 +172,9 @@ export default function BenchmarkPage() { aspects: m.aspects, ks: m.ks ?? [], }); - setStage((prev) => prev ?? pickDefaultStage(m.stages)); - setSelectedK((prev) => prev ?? (m.ks?.[0] ?? null)); + // Only seed a default if the URL didn't already pin one. + if (stage == null) setStage(pickDefaultStage(m.stages)); + if (selectedK == null) setSelectedK(m.ks?.[0] ?? null); }) .catch((e) => setError(e.message)); }, []); @@ -568,9 +573,9 @@ export default function BenchmarkPage() {

) : ( -
+
- + of any long data table. */ +.protea-thead-sticky { + position: sticky; + top: 4rem; /* 64px = h-16 */ + z-index: 10; + background: white; + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.08); +} + +/* Scroll-shadow on horizontally scrollable wrappers. The trick is + four stacked layers in `background`: + 1+2: solid white "covers" anchored to the start/end (background-attachment: local) + so they hide as the user scrolls into the content. + 3+4: faint shadows anchored to the scroll container (background-attachment: scroll) + revealed once the white covers move out of the way. + Apply on a `
`. */ +.protea-scroll-shadow { + background-image: + linear-gradient(to right, white 30%, rgba(255, 255, 255, 0)), + linear-gradient(to right, rgba(255, 255, 255, 0), white 70%) 100% 0, + radial-gradient(farthest-side at 0 50%, rgba(15, 23, 42, 0.12), rgba(0, 0, 0, 0)), + radial-gradient(farthest-side at 100% 50%, rgba(15, 23, 42, 0.12), rgba(0, 0, 0, 0)) 100% 0; + background-repeat: no-repeat; + background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%; + background-attachment: local, local, scroll, scroll; +} + .protea-grid-bg { background-image: radial-gradient(circle at 1px 1px, rgba(15, 23, 42, 0.06) 1px, transparent 0); diff --git a/apps/web/lib/useUrlParam.ts b/apps/web/lib/useUrlParam.ts new file mode 100644 index 0000000..6952196 --- /dev/null +++ b/apps/web/lib/useUrlParam.ts @@ -0,0 +1,67 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useMemo } from "react"; + +/** + * Two-way binding between a single query-string key and a piece of UI + * state. Refreshing the page or copying the URL preserves the filter, + * which is the whole point — without this, every chip / dropdown + * forgets state on reload. + * + * Returns ``[value, setValue]`` where ``setValue(null)`` removes the + * key entirely (cleanest URL when the filter is at its default). + * + * Uses ``router.replace`` + ``scroll: false`` so changing a chip + * doesn't scroll the page or pollute the back-button stack. + */ +export function useUrlParam( + key: string, + defaultValue: string | null = null, +): readonly [string | null, (next: string | null) => void] { + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + + const value = useMemo(() => { + const v = params.get(key); + return v == null ? defaultValue : v; + }, [params, key, defaultValue]); + + const setValue = useCallback( + (next: string | null) => { + const merged = new URLSearchParams(params.toString()); + if (next == null || next === "" || next === defaultValue) { + merged.delete(key); + } else { + merged.set(key, next); + } + const qs = merged.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, + [router, pathname, params, key, defaultValue], + ); + + return [value, setValue] as const; +} + +/** Convenience: number-typed variant. Returns null when missing or invalid. */ +export function useUrlNumber( + key: string, + defaultValue: number | null = null, +): readonly [number | null, (next: number | null) => void] { + const [raw, setRaw] = useUrlParam( + key, + defaultValue == null ? null : String(defaultValue), + ); + const value = useMemo(() => { + if (raw == null) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; + }, [raw]); + const setValue = useCallback( + (next: number | null) => setRaw(next == null ? null : String(next)), + [setRaw], + ); + return [value, setValue] as const; +}
{/* Desktop table */} -
-
+
+
{t("configsTab.tableHeaders.description")}
{t("configsTab.tableHeaders.model")}
{t("configsTab.tableHeaders.backend")}
diff --git a/apps/web/app/[locale]/jobs/page.tsx b/apps/web/app/[locale]/jobs/page.tsx index 14d6d93..876faee 100644 --- a/apps/web/app/[locale]/jobs/page.tsx +++ b/apps/web/app/[locale]/jobs/page.tsx @@ -7,6 +7,7 @@ import { StatusBadge } from "@/components/StatusBadge"; import { SkeletonTableRow } from "@/components/Skeleton"; import { useToast } from "@/components/Toast"; import { useTranslations } from "next-intl"; +import { useUrlParam } from "@/lib/useUrlParam"; const STATUS_OPTIONS = ["", "queued", "running", "succeeded", "failed", "cancelled"]; @@ -51,7 +52,9 @@ export default function JobsPage() { const toast = useToast(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); - const [statusFilter, setStatusFilter] = useState(""); + const [statusFilterRaw, setStatusFilterRaw] = useUrlParam("status", ""); + const statusFilter = statusFilterRaw ?? ""; + const setStatusFilter = (v: string) => setStatusFilterRaw(v === "" ? null : v); const [error, setError] = useState(""); const [autoRefresh, setAutoRefresh] = useState(false); const intervalRef = useRef | null>(null); diff --git a/apps/web/app/[locale]/proteins/page.tsx b/apps/web/app/[locale]/proteins/page.tsx index 224b1bc..0e1f6d8 100644 --- a/apps/web/app/[locale]/proteins/page.tsx +++ b/apps/web/app/[locale]/proteins/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useToast } from "@/components/Toast"; import { SkeletonTableRow } from "@/components/Skeleton"; import { useTranslations } from "next-intl"; +import { useUrlParam } from "@/lib/useUrlParam"; import { getProteinStats, listProteins, @@ -41,7 +42,13 @@ function StatCard({ label, value, sub }: { label: string; value: number; sub?: s export default function ProteinsPage() { const t = useTranslations("proteins"); const toast = useToast(); - const [activeTab, setActiveTab] = useState("browse"); + // URL-synced active tab — shareable links land on the right one. + const [tabRaw, setTabRaw] = useUrlParam("tab", "browse"); + const validTabs: Tab[] = ["browse", "stats", "insert", "metadata"]; + const activeTab: Tab = (validTabs.includes((tabRaw ?? "browse") as Tab) + ? (tabRaw ?? "browse") + : "browse") as Tab; + const setActiveTab = (t: Tab) => setTabRaw(t === "browse" ? null : t); // Browse state const [proteins, setProteins] = useState([]); @@ -270,8 +277,8 @@ export default function ProteinsPage() {
{/* Desktop table */} -
-
+
+
{t("browseTab.tableHeaders.accession")}
{t("browseTab.tableHeaders.entryName")}
{t("browseTab.tableHeaders.gene")}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0bdeefe..31050d3 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -96,6 +96,34 @@ h1, h2, h3, h4 { color: transparent; } +/* Sticky table header — sits below the sticky h-16 chrome header. + Apply on the