diff --git a/app/katalog/[id]/page.tsx b/app/katalog/[id]/page.tsx index a0bc689..43ab725 100644 --- a/app/katalog/[id]/page.tsx +++ b/app/katalog/[id]/page.tsx @@ -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 ( + + + + ); +} + +function DatasetDetailInner() { const t = useT(); const { id } = useParams<{ id: string }>(); const reveal = useReveal(); + 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(null); - const [tab, setTab] = useState("overview"); const attempt = useRef(0); const load = useCallback(() => { @@ -195,7 +224,7 @@ export default function DatasetDetailPage() {
- + {t({ en: "← Back to the catalog", sv: "← Till katalogen" })}
@@ -211,14 +240,14 @@ export default function DatasetDetailPage() { ctaLabel={t({ en: "Compose a job", sv: "Komponera ett jobb" })} />
- + {t({ en: "← Back to the catalog", sv: "← Till katalogen" })}
) : null} - {status === "ready" && dataset ? : null} + {status === "ready" && dataset ? : null} ); @@ -249,7 +278,7 @@ function DatasetView({ d, tab, setTab }: { d: Dataset; tab: TabKey; setTab: (k:
{/* breadcrumb */}
- + {t({ en: "Catalog", sv: "Katalog" })} / diff --git a/app/katalog/page.tsx b/app/katalog/page.tsx index c8b3812..09eaf89 100644 --- a/app/katalog/page.tsx +++ b/app/katalog/page.tsx @@ -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 ( + + + + ); +} + +function CatalogInner() { const t = useT(); const ref = useReveal(); const router = useRouter(); + const searchParams = useSearchParams(); const [items, setItems] = useState(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 | 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) => (
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"