feat(katalog): filter + sök + flikval i URL:en (URL state-konventionen) #124

Merged
admin merged 1 commits from feat/katalog-urlstate-20260928 into main 2026-09-28 14:41:41 +00:00
2 changed files with 85 additions and 14 deletions
+36 -7
View File
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Header } from "@/components/header";
import { useT } from "@/components/locale-provider";
import { DataArtifact } from "@/components/data-artifact";
@@ -142,14 +142,43 @@ function findDataset(raw: string | undefined): Dataset | undefined {
return CATALOG.find((d) => d.id === slug);
}
const TAB_KEYS = TABS.map((t) => t.key);
function isTabKey(value: string | null): value is TabKey {
return value !== null && (TAB_KEYS as string[]).includes(value);
}
/**
* Flikvalet lever i URL:en (?tab=…, kernel-konventionen: URL state är kanoniskt)
* — "Tillbaka" återställer flik, och en delad länk öppnar samma flik. Komponenten
* wrappas i Suspense eftersom useSearchParams kräver det vid prerender.
*/
export default function DatasetDetailPage() {
return (
<Suspense fallback={null}>
<DatasetDetailInner />
</Suspense>
);
}
function DatasetDetailInner() {
const t = useT();
const { id } = useParams<{ id: string }>();
const reveal = useReveal<HTMLDivElement>();
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const tab: TabKey = isTabKey(tabParam) ? tabParam : "overview";
function setTabNext(next: TabKey) {
const params = new URLSearchParams(searchParams.toString());
if (next === "overview") params.delete("tab");
else params.set("tab", next);
const qs = params.toString();
router.replace(qs ? `?${qs}` : window.location.pathname, { scroll: false });
}
const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">("loading");
const [dataset, setDataset] = useState<Dataset | null>(null);
const [tab, setTab] = useState<TabKey>("overview");
const attempt = useRef(0);
const load = useCallback(() => {
@@ -195,7 +224,7 @@ export default function DatasetDetailPage() {
<section className="py-24">
<ErrorState title={t({ en: "Couldn't load the dataset", sv: "Kunde inte hämta datasetet" })} onRetry={load} />
<div className="mt-10">
<Link href="/#katalog" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
<Link href="/katalog" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{t({ en: "← Back to the catalog", sv: "← Till katalogen" })}
</Link>
</div>
@@ -211,14 +240,14 @@ export default function DatasetDetailPage() {
ctaLabel={t({ en: "Compose a job", sv: "Komponera ett jobb" })}
/>
<div className="mt-10">
<Link href="/#katalog" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
<Link href="/katalog" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{t({ en: "← Back to the catalog", sv: "← Till katalogen" })}
</Link>
</div>
</section>
) : null}
{status === "ready" && dataset ? <DatasetView d={dataset} tab={tab} setTab={setTab} /> : null}
{status === "ready" && dataset ? <DatasetView d={dataset} tab={tab} setTab={setTabNext} /> : null}
</div>
</main>
);
@@ -249,7 +278,7 @@ function DatasetView({ d, tab, setTab }: { d: Dataset; tab: TabKey; setTab: (k:
<div ref={revealRef}>
{/* breadcrumb */}
<div className="reveal flex items-center gap-2 border-b border-border py-4 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
<Link href="/#katalog" className="underline-offset-4 transition-colors hover:text-foreground hover:underline">
<Link href="/katalog" className="underline-offset-4 transition-colors hover:text-foreground hover:underline">
{t({ en: "Catalog", sv: "Katalog" })}
</Link>
<span className="text-border">/</span>
+49 -7
View File
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "@/components/locale-provider";
import { Header } from "@/components/header";
import { useReveal } from "@/hooks/use-reveal";
@@ -37,14 +37,56 @@ function level(c: number): ProvLevel {
return 0;
}
/**
* Filter + sök lever i URL:en (kernel-konventionen: URL state är kanoniskt och
* återställbart) — "Tillbaka" från en detaljsida återställer exakt vy, och en
* delad länk öppnar samma filtrering. pages-komponenten wrappar i Suspense
* eftersom useSearchParams kräver det vid prerender.
*/
export default function CatalogPage() {
return (
<Suspense fallback={null}>
<CatalogInner />
</Suspense>
);
}
function CatalogInner() {
const t = useT();
const ref = useReveal<HTMLDivElement>();
const router = useRouter();
const searchParams = useSearchParams();
const [items, setItems] = useState<CatalogItem[] | null>(null);
const [err, setErr] = useState(false);
const [filter, setFilter] = useState("all");
const [q, setQ] = useState("");
const filter = searchParams.get("filter") ?? "all";
const q = searchParams.get("q") ?? "";
// Lokalt utkast för sökfältet (skrivtick), synkat debouncerat till URL:en.
const [qDraft, setQDraft] = useState(q);
const urlSync = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { setQDraft(q); }, [q]);
function applyState(next: { q?: string; filter?: string }) {
const params = new URLSearchParams();
const nq = (next.q ?? q).trim().slice(0, 120);
const nf = next.filter ?? filter;
if (nq) params.set("q", nq);
if (nf && nf !== "all") params.set("filter", nf);
const qs = params.toString();
router.replace(qs ? `/katalog?${qs}` : "/katalog", { scroll: false });
}
function setFilterNext(next: string) {
if (urlSync.current) clearTimeout(urlSync.current);
applyState({ filter: next });
}
function setQNext(next: string) {
setQDraft(next);
if (urlSync.current) clearTimeout(urlSync.current);
urlSync.current = setTimeout(() => applyState({ q: next }), 250);
}
useEffect(() => () => { if (urlSync.current) clearTimeout(urlSync.current); }, []);
function load() {
setErr(false);
@@ -89,7 +131,7 @@ export default function CatalogPage() {
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
onClick={() => setFilterNext(f.key)}
aria-pressed={filter === f.key}
className="border px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors"
style={{
@@ -103,8 +145,8 @@ export default function CatalogPage() {
))}
</div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
value={qDraft}
onChange={(e) => setQNext(e.target.value)}
placeholder={t({ en: "Search datasets…", sv: "Sök dataset…" })}
aria-label={t({ en: "Search datasets", sv: "Sök dataset" })}
className="w-full max-w-xs border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground sm:w-auto"