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

@@ -9,7 +9,7 @@ const querySchema = z.object({
year: z.coerce.number().int().optional(), year: z.coerce.number().int().optional(),
sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(), sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(),
dLanguageId: z.string().trim().length(2).optional(), dLanguageId: z.string().trim().length(2).optional(),
dMachinetypeId: z.coerce.number().int().positive().optional(), dMachinetypeId: z.string().optional(),
filetypeId: z.coerce.number().int().positive().optional(), filetypeId: z.coerce.number().int().positive().optional(),
schemetypeId: z.string().trim().length(2).optional(), schemetypeId: z.string().trim().length(2).optional(),
sourcetypeId: z.string().trim().length(1).optional(), sourcetypeId: z.string().trim().length(1).optional(),
@@ -17,6 +17,15 @@ const querySchema = z.object({
isDemo: z.coerce.boolean().optional(), isDemo: z.coerce.boolean().optional(),
}); });
function parseIdList(value: string | undefined) {
if (!value) return undefined;
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({ const parsed = querySchema.safeParse({
@@ -39,7 +48,8 @@ export async function GET(req: NextRequest) {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });
} }
const data = await searchReleases(parsed.data); const dMachinetypeId = parseIdList(parsed.data.dMachinetypeId);
const data = await searchReleases({ ...parsed.data, dMachinetypeId });
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });

View File

@@ -12,12 +12,21 @@ const querySchema = z.object({
.trim() .trim()
.length(2, "languageId must be a 2-char code") .length(2, "languageId must be a 2-char code")
.optional(), .optional(),
machinetypeId: z.coerce.number().int().positive().optional(), machinetypeId: z.string().optional(),
sort: z.enum(["title", "id_desc"]).optional(), sort: z.enum(["title", "id_desc"]).optional(),
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(), scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
facets: z.coerce.boolean().optional(), facets: z.coerce.boolean().optional(),
}); });
function parseIdList(value: string | undefined) {
if (!value) return undefined;
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({ const parsed = querySchema.safeParse({
@@ -37,9 +46,11 @@ export async function GET(req: NextRequest) {
{ status: 400, headers: { "content-type": "application/json" } } { status: 400, headers: { "content-type": "application/json" } }
); );
} }
const data = await searchEntries(parsed.data); const machinetypeId = parseIdList(parsed.data.machinetypeId);
const searchParamsParsed = { ...parsed.data, machinetypeId };
const data = await searchEntries(searchParamsParsed);
const body = parsed.data.facets const body = parsed.data.facets
? { ...data, facets: await getEntryFacets(parsed.data) } ? { ...data, facets: await getEntryFacets(searchParamsParsed) }
: data; : data;
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },

View File

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

View File

@@ -7,12 +7,24 @@ export const metadata = {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function parseIdList(value: string | string[] | undefined) {
if (!value) return undefined;
const raw = Array.isArray(value) ? value.join(",") : value;
const ids = raw
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams; const sp = await searchParams;
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const genreId = (Array.isArray(sp.genreId) ? sp.genreId[0] : sp.genreId) ?? ""; const genreId = (Array.isArray(sp.genreId) ? sp.genreId[0] : sp.genreId) ?? "";
const languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? ""; const languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? "";
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? ""; const preferredMachineIds = [27, 26, 8, 9];
const machinetypeIds = parseIdList(sp.machinetypeId) ?? preferredMachineIds;
const machinetypeId = machinetypeIds.join(",");
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc"; const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc";
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const scope = ((Array.isArray(sp.scope) ? sp.scope[0] : sp.scope) ?? "title") as const scope = ((Array.isArray(sp.scope) ? sp.scope[0] : sp.scope) ?? "title") as
@@ -29,7 +41,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
scope, scope,
genreId: genreId ? Number(genreId) : undefined, genreId: genreId ? Number(genreId) : undefined,
languageId: languageId || undefined, languageId: languageId || undefined,
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined, machinetypeId: machinetypeIds,
}), }),
listGenres(), listGenres(),
listLanguages(), listLanguages(),
@@ -40,7 +52,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
scope, scope,
genreId: genreId ? Number(genreId) : undefined, genreId: genreId ? Number(genreId) : undefined,
languageId: languageId || undefined, languageId: languageId || undefined,
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined, machinetypeId: machinetypeIds,
}), }),
]); ]);

View File

@@ -51,6 +51,16 @@ export default function ReleasesExplorer({
casetypes: { id: string; name: string }[]; casetypes: { id: string; name: string }[];
}; };
}) { }) {
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 router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -64,7 +74,7 @@ export default function ReleasesExplorer({
// Download-based filters and their option lists // Download-based filters and their option lists
const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? ""); const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? "");
const [dMachinetypeId, setDMachinetypeId] = useState<string>(initialUrlState?.dMachinetypeId ?? ""); const [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.dMachinetypeId));
const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? ""); const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? "");
const [schemetypeId, setSchemetypeId] = useState<string>(initialUrlState?.schemetypeId ?? ""); const [schemetypeId, setSchemetypeId] = useState<string>(initialUrlState?.schemetypeId ?? "");
const [sourcetypeId, setSourcetypeId] = useState<string>(initialUrlState?.sourcetypeId ?? ""); const [sourcetypeId, setSourcetypeId] = useState<string>(initialUrlState?.sourcetypeId ?? "");
@@ -78,11 +88,16 @@ export default function ReleasesExplorer({
const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []); const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []);
const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []); const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []);
const initialLoad = useRef(true); const initialLoad = useRef(true);
const preferredMachineIds = [27, 26, 8, 9];
const preferredMachineNames = useMemo(() => { const preferredMachineNames = useMemo(() => {
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`); if (!machines.length) return preferredMachineIds.map((id) => `#${id}`);
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
}, [machines]); }, [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 pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
@@ -94,7 +109,7 @@ export default function ReleasesExplorer({
if (year) params.set("year", year); if (year) params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
@@ -114,7 +129,7 @@ export default function ReleasesExplorer({
if (year) params.set("year", String(Number(year))); if (year) params.set("year", String(Number(year)));
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
@@ -148,7 +163,7 @@ export default function ReleasesExplorer({
(initialUrlState?.year ?? "") === (year ?? "") && (initialUrlState?.year ?? "") === (year ?? "") &&
sort === (initialUrlState?.sort ?? "year_desc") && sort === (initialUrlState?.sort ?? "year_desc") &&
(initialUrlState?.dLanguageId ?? "") === dLanguageId && (initialUrlState?.dLanguageId ?? "") === dLanguageId &&
(initialUrlState?.dMachinetypeId ?? "") === dMachinetypeId && parseMachineIds(initialUrlState?.dMachinetypeId).join(",") === dMachinetypeIds.join(",") &&
(initialUrlState?.filetypeId ?? "") === filetypeId && (initialUrlState?.filetypeId ?? "") === filetypeId &&
(initialUrlState?.schemetypeId ?? "") === schemetypeId && (initialUrlState?.schemetypeId ?? "") === schemetypeId &&
(initialUrlState?.sourcetypeId ?? "") === sourcetypeId && (initialUrlState?.sourcetypeId ?? "") === sourcetypeId &&
@@ -168,7 +183,7 @@ export default function ReleasesExplorer({
} }
updateUrl(page); updateUrl(page);
fetchData(appliedQ, page); fetchData(appliedQ, page);
}, [page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo, appliedQ]); }, [page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo, appliedQ]);
function onSubmit(e: React.FormEvent) { function onSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -209,14 +224,14 @@ export default function ReleasesExplorer({
if (year) params.set("year", year); if (year) params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId); if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1"); if (isDemo) params.set("isDemo", "1");
return `/zxdb/releases?${params.toString()}`; return `/zxdb/releases?${params.toString()}`;
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); }, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
const nextHref = useMemo(() => { const nextHref = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -225,14 +240,14 @@ export default function ReleasesExplorer({
if (year) params.set("year", year); if (year) params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId); if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1"); if (isDemo) params.set("isDemo", "1");
return `/zxdb/releases?${params.toString()}`; return `/zxdb/releases?${params.toString()}`;
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); }, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
return ( return (
<div> <div>
@@ -291,15 +306,34 @@ export default function ReleasesExplorer({
</div> </div>
<div> <div>
<label className="form-label small text-secondary">DL Machine</label> <label className="form-label small text-secondary">DL Machine</label>
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}> <div className="d-flex flex-wrap gap-2">
<option value="">All machines</option> {orderedMachines.map((m) => {
{machines.map((m) => ( const active = dMachinetypeIds.includes(m.id);
<option key={m.id} value={m.id}>{m.name}</option> return (
))} <button
</select> key={m.id}
{dMachinetypeId === "" && ( type="button"
className={`btn btn-sm ${active ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => {
setDMachinetypeIds((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 className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
)}
</div> </div>
<div> <div>
<label className="form-label small text-secondary">File type</label> <label className="form-label small text-secondary">File type</label>

View File

@@ -7,6 +7,16 @@ export const metadata = {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function parseIdList(value: string | string[] | undefined) {
if (!value) return undefined;
const raw = Array.isArray(value) ? value.join(",") : value;
const ids = raw
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams; const sp = await searchParams;
const hasParams = Object.values(sp).some((value) => value !== undefined); const hasParams = Object.values(sp).some((value) => value !== undefined);
@@ -16,8 +26,9 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
const year = yearStr ? Number(yearStr) : undefined; const year = yearStr ? Number(yearStr) : undefined;
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "year_desc") as "year_desc" | "year_asc" | "title" | "entry_id_desc"; const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "year_desc") as "year_desc" | "year_asc" | "title" | "entry_id_desc";
const dLanguageId = (Array.isArray(sp.dLanguageId) ? sp.dLanguageId[0] : sp.dLanguageId) ?? ""; const dLanguageId = (Array.isArray(sp.dLanguageId) ? sp.dLanguageId[0] : sp.dLanguageId) ?? "";
const dMachinetypeIdStr = (Array.isArray(sp.dMachinetypeId) ? sp.dMachinetypeId[0] : sp.dMachinetypeId) ?? ""; const preferredMachineIds = [27, 26, 8, 9];
const dMachinetypeId = dMachinetypeIdStr ? Number(dMachinetypeIdStr) : undefined; const dMachinetypeIds = parseIdList(sp.dMachinetypeId) ?? preferredMachineIds;
const dMachinetypeIdStr = dMachinetypeIds.join(",");
const filetypeIdStr = (Array.isArray(sp.filetypeId) ? sp.filetypeId[0] : sp.filetypeId) ?? ""; const filetypeIdStr = (Array.isArray(sp.filetypeId) ? sp.filetypeId[0] : sp.filetypeId) ?? "";
const filetypeId = filetypeIdStr ? Number(filetypeIdStr) : undefined; const filetypeId = filetypeIdStr ? Number(filetypeIdStr) : undefined;
const schemetypeId = (Array.isArray(sp.schemetypeId) ? sp.schemetypeId[0] : sp.schemetypeId) ?? ""; const schemetypeId = (Array.isArray(sp.schemetypeId) ? sp.schemetypeId[0] : sp.schemetypeId) ?? "";
@@ -27,7 +38,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined; const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined;
const [initial, langs, machines, filetypes, schemes, sources, cases] = await Promise.all([ const [initial, langs, machines, filetypes, schemes, sources, cases] = await Promise.all([
searchReleases({ page, pageSize: 20, q, year, sort, dLanguageId: dLanguageId || undefined, dMachinetypeId, filetypeId, schemetypeId: schemetypeId || undefined, sourcetypeId: sourcetypeId || undefined, casetypeId: casetypeId || undefined, isDemo }), searchReleases({ page, pageSize: 20, q, year, sort, dLanguageId: dLanguageId || undefined, dMachinetypeId: dMachinetypeIds, filetypeId, schemetypeId: schemetypeId || undefined, sourcetypeId: sourcetypeId || undefined, casetypeId: casetypeId || undefined, isDemo }),
listLanguages(), listLanguages(),
listMachinetypes(), listMachinetypes(),
listFiletypes(), listFiletypes(),

View File

@@ -63,7 +63,7 @@ export interface SearchParams {
// Optional simple filters (ANDed together) // Optional simple filters (ANDed together)
genreId?: number; genreId?: number;
languageId?: string; languageId?: string;
machinetypeId?: number; machinetypeId?: number | number[];
// Sorting // Sorting
sort?: "title" | "id_desc"; sort?: "title" | "id_desc";
// Search scope (defaults to titles only) // Search scope (defaults to titles only)
@@ -177,7 +177,10 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const sort = params.sort ?? (q ? "title" : "id_desc"); const sort = params.sort ?? (q ? "title" : "id_desc");
const scope: EntrySearchScope = params.scope ?? "title"; const scope: EntrySearchScope = params.scope ?? "title";
const preferMachineOrder = typeof params.machinetypeId === "number" const hasMachineFilter = Array.isArray(params.machinetypeId)
? params.machinetypeId.length > 0
: typeof params.machinetypeId === "number";
const preferMachineOrder = hasMachineFilter
? null ? null
: sql`case : sql`case
when ${entries.machinetypeId} = 27 then 0 when ${entries.machinetypeId} = 27 then 0
@@ -190,7 +193,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
if (q.length === 0) { if (q.length === 0) {
// Default listing: return first page by id desc (no guaranteed ordering field; using id) // Default listing: return first page by id desc (no guaranteed ordering field; using id)
// Apply optional filters even without q // Apply optional filters even without q
const whereClauses: Array<ReturnType<typeof eq>> = []; const whereClauses: Array<ReturnType<typeof sql>> = [];
if (typeof params.genreId === "number") { if (typeof params.genreId === "number") {
whereClauses.push(eq(entries.genretypeId, params.genreId)); whereClauses.push(eq(entries.genretypeId, params.genreId));
} }
@@ -199,6 +202,9 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
} }
if (typeof params.machinetypeId === "number") { if (typeof params.machinetypeId === "number") {
whereClauses.push(eq(entries.machinetypeId, params.machinetypeId)); whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
} else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
const ids = params.machinetypeId.map((id) => sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
} }
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined; const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
@@ -1655,7 +1661,12 @@ export async function getEntryFacets(params: SearchParams): Promise<EntryFacets>
} }
if (params.genreId) whereParts.push(sql`e.genretype_id = ${params.genreId}`); if (params.genreId) whereParts.push(sql`e.genretype_id = ${params.genreId}`);
if (params.languageId) whereParts.push(sql`e.language_id = ${params.languageId}`); if (params.languageId) whereParts.push(sql`e.language_id = ${params.languageId}`);
if (params.machinetypeId) whereParts.push(sql`e.machinetype_id = ${params.machinetypeId}`); if (typeof params.machinetypeId === "number") {
whereParts.push(sql`e.machinetype_id = ${params.machinetypeId}`);
} else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
const ids = params.machinetypeId.map((id) => sql`${id}`);
whereParts.push(sql`e.machinetype_id in (${sql.join(ids, sql`, `)})`);
}
const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts, sql` and `)], sql``) : sql``; const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts, sql` and `)], sql``) : sql``;
@@ -1733,7 +1744,7 @@ export interface ReleaseSearchParams {
sort?: "year_desc" | "year_asc" | "title" | "entry_id_desc"; sort?: "year_desc" | "year_asc" | "title" | "entry_id_desc";
// Optional download-based filters (matched via EXISTS on downloads) // Optional download-based filters (matched via EXISTS on downloads)
dLanguageId?: string; // downloads.language_id dLanguageId?: string; // downloads.language_id
dMachinetypeId?: number; // downloads.machinetype_id dMachinetypeId?: number | number[]; // downloads.machinetype_id
filetypeId?: number; // downloads.filetype_id filetypeId?: number; // downloads.filetype_id
schemetypeId?: string; // downloads.schemetype_id schemetypeId?: string; // downloads.schemetype_id
sourcetypeId?: string; // downloads.sourcetype_id sourcetypeId?: string; // downloads.sourcetype_id
@@ -1754,7 +1765,10 @@ export async function searchReleases(params: ReleaseSearchParams): Promise<Paged
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1); const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const preferMachineOrder = params.dMachinetypeId != null const hasMachineFilter = Array.isArray(params.dMachinetypeId)
? params.dMachinetypeId.length > 0
: params.dMachinetypeId != null;
const preferMachineOrder = hasMachineFilter
? null ? null
: sql`case : sql`case
when ${entries.machinetypeId} = 27 then 0 when ${entries.machinetypeId} = 27 then 0
@@ -1780,7 +1794,12 @@ export async function searchReleases(params: ReleaseSearchParams): Promise<Paged
// would produce "from `d`" which MySQL interprets as a literal table. // would produce "from `d`" which MySQL interprets as a literal table.
const dlConds: Array<ReturnType<typeof sql>> = []; const dlConds: Array<ReturnType<typeof sql>> = [];
if (params.dLanguageId) dlConds.push(sql`d.language_id = ${params.dLanguageId}`); if (params.dLanguageId) dlConds.push(sql`d.language_id = ${params.dLanguageId}`);
if (params.dMachinetypeId != null) dlConds.push(sql`d.machinetype_id = ${params.dMachinetypeId}`); if (typeof params.dMachinetypeId === "number") {
dlConds.push(sql`d.machinetype_id = ${params.dMachinetypeId}`);
} else if (Array.isArray(params.dMachinetypeId) && params.dMachinetypeId.length > 0) {
const ids = params.dMachinetypeId.map((id) => sql`${id}`);
dlConds.push(sql`d.machinetype_id in (${sql.join(ids, sql`, `)})`);
}
if (params.filetypeId != null) dlConds.push(sql`d.filetype_id = ${params.filetypeId}`); if (params.filetypeId != null) dlConds.push(sql`d.filetype_id = ${params.filetypeId}`);
if (params.schemetypeId) dlConds.push(sql`d.schemetype_id = ${params.schemetypeId}`); if (params.schemetypeId) dlConds.push(sql`d.schemetype_id = ${params.schemetypeId}`);
if (params.sourcetypeId) dlConds.push(sql`d.sourcetype_id = ${params.sourcetypeId}`); if (params.sourcetypeId) dlConds.push(sql`d.sourcetype_id = ${params.sourcetypeId}`);