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
19 changes: 12 additions & 7 deletions apps/web/app/[locale]/benchmark/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -143,9 +144,12 @@ export default function BenchmarkPage() {
const [embeddings, setEmbeddings] = useState<BenchmarkEmbedding[] | null>(null);
const [matrix, setMatrix] = useState<BenchmarkMatrixResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [stage, setStage] = useState<string | null>(null);
const [evalSetId, setEvalSetId] = useState<string | "all">("all");
const [selectedK, setSelectedK] = useState<number | null>(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
Expand All @@ -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));
}, []);
Expand Down Expand Up @@ -568,9 +573,9 @@ export default function BenchmarkPage() {
</p>
</section>
) : (
<div className="overflow-x-auto rounded-lg border bg-white shadow-sm">
<div className="overflow-x-auto protea-scroll-shadow rounded-lg border bg-white shadow-sm">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<thead className="protea-thead-sticky bg-slate-50">
<tr>
<th
rowSpan={2}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/[locale]/embeddings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,8 @@ export default function EmbeddingsPage() {
</div>

{/* Desktop table */}
<div className="hidden lg:block overflow-x-auto rounded-lg border bg-white shadow-sm">
<div className="grid grid-cols-[1fr_140px_80px_100px_80px_80px_60px_160px_60px] gap-2 border-b bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="hidden lg:block overflow-x-auto protea-scroll-shadow rounded-lg border bg-white shadow-sm">
<div className="protea-thead-sticky grid grid-cols-[1fr_140px_80px_100px_80px_80px_60px_160px_60px] gap-2 border-b bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div>{t("configsTab.tableHeaders.description")}</div>
<div>{t("configsTab.tableHeaders.model")}</div>
<div>{t("configsTab.tableHeaders.backend")}</div>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/app/[locale]/jobs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down Expand Up @@ -51,7 +52,9 @@ export default function JobsPage() {
const toast = useToast();
const [jobs, setJobs] = useState<Job[]>([]);
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<ReturnType<typeof setInterval> | null>(null);
Expand Down
13 changes: 10 additions & 3 deletions apps/web/app/[locale]/proteins/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Tab>("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<ProteinItem[]>([]);
Expand Down Expand Up @@ -270,8 +277,8 @@ export default function ProteinsPage() {
</div>

{/* Desktop table */}
<div className="hidden lg:block overflow-x-auto rounded-lg border bg-white shadow-sm">
<div className="grid grid-cols-[130px_140px_120px_1fr_80px_110px] gap-2 border-b bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="hidden lg:block overflow-x-auto protea-scroll-shadow rounded-lg border bg-white shadow-sm">
<div className="protea-thead-sticky grid grid-cols-[130px_140px_120px_1fr_80px_110px] gap-2 border-b bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div>{t("browseTab.tableHeaders.accession")}</div>
<div>{t("browseTab.tableHeaders.entryName")}</div>
<div>{t("browseTab.tableHeaders.gene")}</div>
Expand Down
28 changes: 28 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,34 @@ h1, h2, h3, h4 {
color: transparent;
}

/* Sticky table header — sits below the sticky h-16 chrome header.
Apply on the <thead> 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 `<div className="overflow-x-auto protea-scroll-shadow">`. */
.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);
Expand Down
67 changes: 67 additions & 0 deletions apps/web/lib/useUrlParam.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading