feat(katalog): filter + sök + flikval i URL:en (URL state-konventionen) #124
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user