Files
explorer/src/app/zxdb/entries/EntriesExplorer.tsx
D. Rimron-Soutter 84dee2710c Add genre column to entries
Include genre data in entry search results and show it
in the entries table layout.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 21:53:31 +00:00

456 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Item = {
id: number;
title: string;
isXrated: number;
genreId: number | null;
genreName?: string | null;
machinetypeId: number | null;
machinetypeName?: string | null;
languageId: string | null;
languageName?: string | null;
};
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
type EntryFacets = {
genres: { id: number; name: string; count: number }[];
languages: { id: string; name: string; count: number }[];
machinetypes: { id: number; name: string; count: number }[];
flags: { hasAliases: number; hasOrigins: number };
};
export default function EntriesExplorer({
initial,
initialGenres,
initialLanguages,
initialMachines,
initialFacets,
initialUrlState,
}: {
initial?: Paged<Item>;
initialGenres?: { id: number; name: string }[];
initialLanguages?: { id: string; name: string }[];
initialMachines?: { id: number; name: string }[];
initialFacets?: EntryFacets | null;
initialUrlState?: {
q: string;
page: number;
genreId: string | number | "";
languageId: string | "";
machinetypeId: string | number | "";
sort: "title" | "id_desc";
scope?: SearchScope;
};
}) {
const router = useRouter();
const pathname = usePathname();
const [q, setQ] = useState(initialUrlState?.q ?? "");
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []);
const [genreId, setGenreId] = useState<number | "">(
initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : ""
);
const [languageId, setLanguageId] = useState<string | "">(initialUrlState?.languageId ?? "");
const [machinetypeId, setMachinetypeId] = useState<number | "">(
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
);
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
const activeFilters = useMemo(() => {
const chips: string[] = [];
if (q) chips.push(`q: ${q}`);
if (genreId !== "") {
const name = genres.find((g) => g.id === Number(genreId))?.name ?? `#${genreId}`;
chips.push(`genre: ${name}`);
}
if (languageId !== "") {
const name = languages.find((l) => l.id === languageId)?.name ?? languageId;
chips.push(`lang: ${name}`);
}
if (machinetypeId !== "") {
const name = machines.find((m) => m.id === Number(machinetypeId))?.name ?? `#${machinetypeId}`;
chips.push(`machine: ${name}`);
}
if (scope === "title_aliases") chips.push("scope: titles + aliases");
if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins");
return chips;
}, [q, genreId, languageId, machinetypeId, scope, genres, languages, machines]);
function updateUrl(nextPage = page) {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(nextPage));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname);
}
async function fetchData(query: string, p: number, withFacets: boolean) {
setLoading(true);
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("page", String(p));
params.set("pageSize", String(pageSize));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
if (withFacets) params.set("facets", "true");
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = await res.json();
setData(json);
if (withFacets && json.facets) {
setFacets(json.facets as EntryFacets);
}
} catch (e) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}
// Sync from SSR payload on navigation
useEffect(() => {
if (initial) {
setData(initial);
setPage(initial.page);
}
}, [initial]);
// Client fetch when filters/paging/sort change; also keep URL in sync
useEffect(() => {
// Avoid extra fetch if SSR already matches this exact default state
const initialPage = initial?.page ?? 1;
if (
initial &&
page === initialPage &&
(initialUrlState?.q ?? "") === q &&
(initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) &&
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
(machinetypeId === "" ? "" : Number(machinetypeId)) &&
sort === (initialUrlState?.sort ?? "id_desc") &&
(initialUrlState?.scope ?? "title") === scope
) {
updateUrl(page);
return;
}
updateUrl(page);
fetchData(q, page, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeId, sort, scope]);
// Load filter lists on mount only if not provided by server
useEffect(() => {
if (initialGenres && initialLanguages && initialMachines) return;
async function loadLists() {
try {
const [g, l, m] = await Promise.all([
fetch("/api/zxdb/genres", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()),
]);
setGenres(g.items ?? []);
setLanguages(l.items ?? []);
setMachines(m.items ?? []);
} catch {}
}
loadLists();
}, [initialGenres, initialLanguages, initialMachines]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setPage(1);
updateUrl(1);
fetchData(q, 1, true);
}
function resetFilters() {
setQ("");
setGenreId("");
setLanguageId("");
setMachinetypeId("");
setSort("id_desc");
setScope("title");
setPage(1);
}
const prevHref = useMemo(() => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1)));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
const nextHref = useMemo(() => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1)));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Entries" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Entries</h1>
<div className="text-secondary">
{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
</div>
</div>
{activeFilters.length > 0 && (
<div className="d-flex flex-wrap gap-2 align-items-center">
{activeFilters.map((chip) => (
<span key={chip} className="badge text-bg-light">{chip}</span>
))}
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={resetFilters}>
Clear filters
</button>
</div>
)}
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input
type="text"
className="form-control"
placeholder="Search titles..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="d-grid">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div>
<div>
<label className="form-label small text-secondary">Genre</label>
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">All genres</option>
{genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Language</label>
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Machine</label>
<select className="form-select" value={machinetypeId} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">All machines</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Sort</label>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
<option value="title">Title (AZ)</option>
<option value="id_desc">Newest</option>
</select>
</div>
<div>
<label className="form-label small text-secondary">Search scope</label>
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
<option value="title">Titles</option>
<option value="title_aliases">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select>
</div>
{facets && (
<div>
<div className="text-secondary small mb-1">Facets</div>
<div className="d-flex flex-wrap gap-2">
<button
type="button"
className={`btn btn-sm ${scope === "title_aliases" ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => { setScope("title_aliases"); setPage(1); }}
disabled={facets.flags.hasAliases === 0}
title="Show results that match aliases"
>
Has aliases ({facets.flags.hasAliases})
</button>
<button
type="button"
className={`btn btn-sm ${scope === "title_aliases_origins" ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => { setScope("title_aliases_origins"); setPage(1); }}
disabled={facets.flags.hasOrigins === 0}
title="Show results that match origins"
>
Has origins ({facets.flags.hasOrigins})
</button>
</div>
</div>
)}
{loading && <div className="text-secondary small">Loading...</div>}
</form>
</div>
</div>
</div>
<div className="col-lg-9">
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 160 }}>Genre</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
{data.items.map((it) => (
<tr key={it.id}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.genreId != null ? (
it.genreName ? (
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link>
) : (
<span>{it.genreId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="d-flex align-items-center gap-2 mt-4">
<span>Page {data?.page ?? 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={prevHref}
onClick={(e) => {
if (!data || data.page <= 1) return;
e.preventDefault();
setPage((p) => Math.max(1, p - 1));
}}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={nextHref}
onClick={(e) => {
if (!data || data.page >= totalPages) return;
e.preventDefault();
setPage((p) => Math.min(totalPages, p + 1));
}}
>
Next
</Link>
</div>
</div>
<hr />
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</Link>
</div>
</div>
);
}