Include genre data in entry search results and show it in the entries table layout. Signed-off-by: codex@lucy.xalior.com
456 lines
18 KiB
TypeScript
456 lines
18 KiB
TypeScript
"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 (A–Z)</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>
|
||
);
|
||
}
|