Add multi-select machine filters

Replace machine dropdowns with multi-select chips and pass machine lists in queries.

Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
2026-01-11 13:04:41 +00:00
parent 2f93ed1774
commit 1e8925e631
7 changed files with 193 additions and 65 deletions

View File

@@ -52,11 +52,21 @@ export default function EntriesExplorer({
page: number;
genreId: string | number | "";
languageId: string | "";
machinetypeId: string | number | "";
machinetypeId: string;
sort: "title" | "id_desc";
scope?: SearchScope;
};
}) {
const preferredMachineIds = [27, 26, 8, 9];
const parseMachineIds = (value?: string) => {
if (!value) return preferredMachineIds.slice();
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : preferredMachineIds.slice();
};
const router = useRouter();
const pathname = usePathname();
@@ -72,17 +82,20 @@ export default function EntriesExplorer({
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 [machinetypeIds, setMachinetypeIds] = useState<number[]>(parseMachineIds(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 preferredMachineIds = [27, 26, 8, 9];
const preferredMachineNames = useMemo(() => {
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`);
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
}, [machines]);
const orderedMachines = useMemo(() => {
const seen = new Set(preferredMachineIds);
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[];
const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest];
}, [machines]);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
@@ -97,14 +110,14 @@ export default function EntriesExplorer({
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 (machinetypeIds.length > 0) {
const names = machinetypeIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
chips.push(`machine: ${names.join(", ")}`);
}
if (scope === "title_aliases") chips.push("scope: titles + aliases");
if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins");
return chips;
}, [appliedQ, genreId, languageId, machinetypeId, scope, genres, languages, machines]);
}, [appliedQ, genreId, languageId, machinetypeIds, scope, genres, languages, machines]);
function updateUrl(nextPage = page) {
const params = new URLSearchParams();
@@ -112,7 +125,7 @@ export default function EntriesExplorer({
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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
const qs = params.toString();
@@ -128,7 +141,7 @@ export default function EntriesExplorer({
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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
if (withFacets) params.set("facets", "true");
@@ -165,8 +178,7 @@ export default function EntriesExplorer({
(initialUrlState?.q ?? "") === appliedQ &&
(initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) &&
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
(machinetypeId === "" ? "" : Number(machinetypeId)) &&
parseMachineIds(initialUrlState?.machinetypeId).join(",") === machinetypeIds.join(",") &&
sort === (initialUrlState?.sort ?? "id_desc") &&
(initialUrlState?.scope ?? "title") === scope
) {
@@ -176,7 +188,7 @@ export default function EntriesExplorer({
updateUrl(page);
fetchData(appliedQ, page, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeId, sort, scope, appliedQ]);
}, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]);
// Load filter lists on mount only if not provided by server
useEffect(() => {
@@ -207,7 +219,7 @@ export default function EntriesExplorer({
setAppliedQ("");
setGenreId("");
setLanguageId("");
setMachinetypeId("");
setMachinetypeIds(preferredMachineIds.slice());
setSort("id_desc");
setScope("title");
setPage(1);
@@ -219,11 +231,11 @@ export default function EntriesExplorer({
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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [appliedQ, data?.page, genreId, languageId, machinetypeId, sort, scope]);
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
const nextHref = useMemo(() => {
const params = new URLSearchParams();
@@ -231,11 +243,11 @@ export default function EntriesExplorer({
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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [appliedQ, data?.page, genreId, languageId, machinetypeId, sort, scope]);
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
return (
<div>
@@ -303,15 +315,34 @@ export default function EntriesExplorer({
</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>
{machinetypeId === "" && (
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
)}
<div className="d-flex flex-wrap gap-2">
{orderedMachines.map((m) => {
const active = machinetypeIds.includes(m.id);
return (
<button
key={m.id}
type="button"
className={`btn btn-sm ${active ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => {
setMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(m.id)) {
next.delete(m.id);
} else {
next.add(m.id);
}
const order = orderedMachines.map((item) => item.id);
return order.filter((id) => next.has(id));
});
setPage(1);
}}
>
{m.name}
</button>
);
})}
</div>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">Sort</label>