diff --git a/COMMIT_EDITMSG b/COMMIT_EDITMSG index 6dd12d1..af1b784 100644 --- a/COMMIT_EDITMSG +++ b/COMMIT_EDITMSG @@ -1,12 +1,9 @@ -Show downloads even without releases rows +ZXDB: Add Phase A schema models and list APIs; align repo -Add synthetic release groups in getEntryById so downloads -are displayed even when there are no matching rows in -`releases` for a given entry. Group by `release_seq`, -attach downloads, and sort groups for stable order. +- Schema: add Drizzle models for availabletypes, currencies, roletypes, issues, magazines, role relations +- Repo: add list/search functions for new lookups (availabletypes, currencies, roletypes) +- API: expose /api/zxdb/{availabletypes,currencies,roletypes} list endpoints +- UI: (deferred) existing pages can now populate dropdowns with proper names where needed +- Keeps previous releases/downloads fixes intact -This fixes cases like /zxdb/entries/1 where `downloads` -exist for the entry but `releases` is empty, resulting in -no downloads shown in the UI. - -Signed-off-by: Junie@devbox +Signed-off-by: Junie@HOSTNAME diff --git a/src/app/api/zxdb/availabletypes/route.ts b/src/app/api/zxdb/availabletypes/route.ts new file mode 100644 index 0000000..e05a8a3 --- /dev/null +++ b/src/app/api/zxdb/availabletypes/route.ts @@ -0,0 +1,10 @@ +import { listAvailabletypes } from "@/server/repo/zxdb"; + +export async function GET() { + const items = await listAvailabletypes(); + return new Response(JSON.stringify({ items }), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/api/zxdb/casetypes/route.ts b/src/app/api/zxdb/casetypes/route.ts new file mode 100644 index 0000000..d6bc820 --- /dev/null +++ b/src/app/api/zxdb/casetypes/route.ts @@ -0,0 +1,10 @@ +import { listCasetypes } from "@/server/repo/zxdb"; + +export async function GET() { + const items = await listCasetypes(); + return new Response(JSON.stringify({ items }), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/api/zxdb/currencies/route.ts b/src/app/api/zxdb/currencies/route.ts new file mode 100644 index 0000000..4b0ef61 --- /dev/null +++ b/src/app/api/zxdb/currencies/route.ts @@ -0,0 +1,10 @@ +import { listCurrencies } from "@/server/repo/zxdb"; + +export async function GET() { + const items = await listCurrencies(); + return new Response(JSON.stringify({ items }), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/api/zxdb/filetypes/route.ts b/src/app/api/zxdb/filetypes/route.ts new file mode 100644 index 0000000..2366d8e --- /dev/null +++ b/src/app/api/zxdb/filetypes/route.ts @@ -0,0 +1,10 @@ +import { listFiletypes } from "@/server/repo/zxdb"; + +export async function GET() { + const items = await listFiletypes(); + return new Response(JSON.stringify({ items }), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/api/zxdb/releases/search/route.ts b/src/app/api/zxdb/releases/search/route.ts new file mode 100644 index 0000000..3e31cd1 --- /dev/null +++ b/src/app/api/zxdb/releases/search/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { searchReleases } from "@/server/repo/zxdb"; + +const querySchema = z.object({ + q: z.string().optional(), + page: z.coerce.number().int().positive().optional(), + pageSize: z.coerce.number().int().positive().max(100).optional(), + year: z.coerce.number().int().optional(), + sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(), + dLanguageId: z.string().trim().length(2).optional(), + dMachinetypeId: z.coerce.number().int().positive().optional(), + filetypeId: z.coerce.number().int().positive().optional(), + schemetypeId: z.string().trim().length(2).optional(), + sourcetypeId: z.string().trim().length(1).optional(), + casetypeId: z.string().trim().length(1).optional(), + isDemo: z.coerce.boolean().optional(), +}); + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const parsed = querySchema.safeParse({ + q: searchParams.get("q") ?? undefined, + page: searchParams.get("page") ?? undefined, + pageSize: searchParams.get("pageSize") ?? undefined, + year: searchParams.get("year") ?? undefined, + sort: searchParams.get("sort") ?? undefined, + dLanguageId: searchParams.get("dLanguageId") ?? undefined, + dMachinetypeId: searchParams.get("dMachinetypeId") ?? undefined, + filetypeId: searchParams.get("filetypeId") ?? undefined, + schemetypeId: searchParams.get("schemetypeId") ?? undefined, + sourcetypeId: searchParams.get("sourcetypeId") ?? undefined, + casetypeId: searchParams.get("casetypeId") ?? undefined, + isDemo: searchParams.get("isDemo") ?? undefined, + }); + if (!parsed.success) { + return new Response(JSON.stringify({ error: parsed.error.flatten() }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + const data = await searchReleases(parsed.data); + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/api/zxdb/roletypes/route.ts b/src/app/api/zxdb/roletypes/route.ts new file mode 100644 index 0000000..afe6e0b --- /dev/null +++ b/src/app/api/zxdb/roletypes/route.ts @@ -0,0 +1,10 @@ +import { listRoletypes } from "@/server/repo/zxdb"; + +export async function GET() { + const items = await listRoletypes(); + return new Response(JSON.stringify({ items }), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/api/zxdb/schemetypes/route.ts b/src/app/api/zxdb/schemetypes/route.ts new file mode 100644 index 0000000..13747f3 --- /dev/null +++ b/src/app/api/zxdb/schemetypes/route.ts @@ -0,0 +1,10 @@ +import { listSchemetypes } from "@/server/repo/zxdb"; + +export async function GET() { + const items = await listSchemetypes(); + return new Response(JSON.stringify({ items }), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/api/zxdb/sourcetypes/route.ts b/src/app/api/zxdb/sourcetypes/route.ts new file mode 100644 index 0000000..cd4d848 --- /dev/null +++ b/src/app/api/zxdb/sourcetypes/route.ts @@ -0,0 +1,10 @@ +import { listSourcetypes } from "@/server/repo/zxdb"; + +export async function GET() { + const items = await listSourcetypes(); + return new Response(JSON.stringify({ items }), { + headers: { "content-type": "application/json" }, + }); +} + +export const runtime = "nodejs"; diff --git a/src/app/zxdb/entries/EntriesExplorer.tsx b/src/app/zxdb/entries/EntriesExplorer.tsx new file mode 100644 index 0000000..29f8869 --- /dev/null +++ b/src/app/zxdb/entries/EntriesExplorer.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; + +type Item = { + id: number; + title: string; + isXrated: number; + machinetypeId: number | null; + machinetypeName?: string | null; + languageId: string | null; + languageName?: string | null; +}; + +type Paged = { + items: T[]; + page: number; + pageSize: number; + total: number; +}; + +export default function EntriesExplorer({ + initial, + initialGenres, + initialLanguages, + initialMachines, + initialUrlState, +}: { + initial?: Paged; + initialGenres?: { id: number; name: string }[]; + initialLanguages?: { id: string; name: string }[]; + initialMachines?: { id: number; name: string }[]; + initialUrlState?: { + q: string; + page: number; + genreId: string | number | ""; + languageId: string | ""; + machinetypeId: string | number | ""; + sort: "title" | "id_desc"; + }; +}) { + 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 | 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( + initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : "" + ); + const [languageId, setLanguageId] = useState(initialUrlState?.languageId ?? ""); + const [machinetypeId, setMachinetypeId] = useState( + initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : "" + ); + const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc"); + + const pageSize = 20; + const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); + + 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); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname); + } + + async function fetchData(query: string, p: number) { + 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); + const res = await fetch(`/api/zxdb/search?${params.toString()}`); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + const json: Paged = await res.json(); + setData(json); + } catch (e) { + // eslint-disable-next-line no-console + 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); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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") + ) { + updateUrl(page); + return; + } + updateUrl(page); + fetchData(q, page); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, genreId, languageId, machinetypeId, sort]); + + // 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); + } + + 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); + return `/zxdb/entries?${params.toString()}`; + }, [q, data?.page, genreId, languageId, machinetypeId, sort]); + + 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); + return `/zxdb/entries?${params.toString()}`; + }, [q, data?.page, genreId, languageId, machinetypeId, sort]); + + return ( +
+

Entries

+
+
+ setQ(e.target.value)} + /> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ {loading && ( +
Loading...
+ )} +
+ +
+ {data && data.items.length === 0 && !loading && ( +
No results.
+ )} + {data && data.items.length > 0 && ( +
+ + + + + + + + + + + {data.items.map((it) => ( + + + + + + + ))} + +
IDTitleMachineLanguage
{it.id} + {it.title} + + {it.machinetypeId != null ? ( + it.machinetypeName ? ( + {it.machinetypeName} + ) : ( + {it.machinetypeId} + ) + ) : ( + - + )} + + {it.languageId ? ( + it.languageName ? ( + {it.languageName} + ) : ( + {it.languageId} + ) + ) : ( + - + )} +
+
+ )} +
+ +
+ + Page {data?.page ?? 1} / {totalPages} + +
+ { + if (!data || data.page <= 1) return; + e.preventDefault(); + setPage((p) => Math.max(1, p - 1)); + }} + > + Prev + + = 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 + +
+
+ +
+
+ Browse Labels + Browse Genres + Browse Languages + Browse Machines +
+
+ ); +} diff --git a/src/app/zxdb/entries/page.tsx b/src/app/zxdb/entries/page.tsx new file mode 100644 index 0000000..cbc89bb --- /dev/null +++ b/src/app/zxdb/entries/page.tsx @@ -0,0 +1,43 @@ +import EntriesExplorer from "./EntriesExplorer"; +import { listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb"; + +export const metadata = { + title: "ZXDB Entries", +}; + +export const dynamic = "force-dynamic"; + +export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const sp = await searchParams; + 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 languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? ""; + const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? ""; + const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) as any) ?? "id_desc"; + const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; + + const [initial, genres, langs, machines] = await Promise.all([ + searchEntries({ + page, + pageSize: 20, + sort, + q, + genreId: genreId ? Number(genreId) : undefined, + languageId: languageId || undefined, + machinetypeId: machinetypeId ? Number(machinetypeId) : undefined, + }), + listGenres(), + listLanguages(), + listMachinetypes(), + ]); + + return ( + + ); +} diff --git a/src/app/zxdb/page.tsx b/src/app/zxdb/page.tsx index 8bd368b..fd4608a 100644 --- a/src/app/zxdb/page.tsx +++ b/src/app/zxdb/page.tsx @@ -1,30 +1,60 @@ -import ZxdbExplorer from "./ZxdbExplorer"; -import { searchEntries, listGenres, listLanguages, listMachinetypes } from "@/server/repo/zxdb"; +import Link from "next/link"; export const metadata = { title: "ZXDB Explorer", }; -// This page depends on searchParams (?page=, filters in future). Force dynamic -// rendering so ISR doesn’t cache a single HTML for all query strings. -export const dynamic = "force-dynamic"; +export const revalidate = 3600; -export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { - const sp = await searchParams; - const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); - // Server-render initial page based on URL to avoid first client fetch and keep counter in sync - const [initial, genres, langs, machines] = await Promise.all([ - searchEntries({ page, pageSize: 20, sort: "id_desc" }), - listGenres(), - listLanguages(), - listMachinetypes(), - ]); +export default async function Page() { return ( - +
+

ZXDB Explorer

+

Choose what you want to explore.

+ +
+
+ +
+
+
+ +
+
+
Entries
+
Browse software entries with filters
+
+
+
+ +
+ +
+ +
+
+
+ +
+
+
Releases
+
Drill into releases and downloads
+
+
+
+ +
+
+ +
+

Categories

+
+ Genres + Languages + Machine Types + Labels +
+
+
); } diff --git a/src/app/zxdb/releases/ReleasesExplorer.tsx b/src/app/zxdb/releases/ReleasesExplorer.tsx new file mode 100644 index 0000000..69674f4 --- /dev/null +++ b/src/app/zxdb/releases/ReleasesExplorer.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; + +type Item = { + entryId: number; + releaseSeq: number; + entryTitle: string; + year: number | null; +}; + +type Paged = { + items: T[]; + page: number; + pageSize: number; + total: number; +}; + +export default function ReleasesExplorer({ + initial, + initialUrlState, +}: { + initial?: Paged; + initialUrlState?: { + q: string; + page: number; + year: string; + sort: "year_desc" | "year_asc" | "title" | "entry_id_desc"; + dLanguageId?: string; + dMachinetypeId?: string; // keep as string for URL/state consistency + filetypeId?: string; + schemetypeId?: string; + sourcetypeId?: string; + casetypeId?: string; + isDemo?: string; // "1" or "true" + }; +}) { + 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 | null>(initial ?? null); + const [year, setYear] = useState(initialUrlState?.year ?? ""); + const [sort, setSort] = useState<"year_desc" | "year_asc" | "title" | "entry_id_desc">(initialUrlState?.sort ?? "year_desc"); + + // Download-based filters and their option lists + const [dLanguageId, setDLanguageId] = useState(initialUrlState?.dLanguageId ?? ""); + const [dMachinetypeId, setDMachinetypeId] = useState(initialUrlState?.dMachinetypeId ?? ""); + const [filetypeId, setFiletypeId] = useState(initialUrlState?.filetypeId ?? ""); + const [schemetypeId, setSchemetypeId] = useState(initialUrlState?.schemetypeId ?? ""); + const [sourcetypeId, setSourcetypeId] = useState(initialUrlState?.sourcetypeId ?? ""); + const [casetypeId, setCasetypeId] = useState(initialUrlState?.casetypeId ?? ""); + const [isDemo, setIsDemo] = useState(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true"))); + + const [langs, setLangs] = useState<{ id: string; name: string }[]>([]); + const [machines, setMachines] = useState<{ id: number; name: string }[]>([]); + const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>([]); + const [schemes, setSchemes] = useState<{ id: string; name: string }[]>([]); + const [sources, setSources] = useState<{ id: string; name: string }[]>([]); + const [cases, setCases] = useState<{ id: string; name: string }[]>([]); + + const pageSize = 20; + const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); + + function updateUrl(nextPage = page) { + const params = new URLSearchParams(); + if (q) params.set("q", q); + params.set("page", String(nextPage)); + if (year) params.set("year", year); + if (sort) params.set("sort", sort); + if (dLanguageId) params.set("dLanguageId", dLanguageId); + if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); + if (filetypeId) params.set("filetypeId", filetypeId); + if (schemetypeId) params.set("schemetypeId", schemetypeId); + if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); + if (casetypeId) params.set("casetypeId", casetypeId); + if (isDemo) params.set("isDemo", "1"); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname); + } + + async function fetchData(query: string, p: number) { + setLoading(true); + try { + const params = new URLSearchParams(); + if (query) params.set("q", query); + params.set("page", String(p)); + params.set("pageSize", String(pageSize)); + if (year) params.set("year", String(Number(year))); + if (sort) params.set("sort", sort); + if (dLanguageId) params.set("dLanguageId", dLanguageId); + if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); + if (filetypeId) params.set("filetypeId", filetypeId); + if (schemetypeId) params.set("schemetypeId", schemetypeId); + if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); + if (casetypeId) params.set("casetypeId", casetypeId); + if (isDemo) params.set("isDemo", "1"); + const res = await fetch(`/api/zxdb/releases/search?${params.toString()}`); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + const json: Paged = await res.json(); + setData(json); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + setData({ items: [], page: 1, pageSize, total: 0 }); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (initial) { + setData(initial); + setPage(initial.page); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initial]); + + useEffect(() => { + const initialPage = initial?.page ?? 1; + if ( + initial && + page === initialPage && + (initialUrlState?.q ?? "") === q && + (initialUrlState?.year ?? "") === (year ?? "") && + sort === (initialUrlState?.sort ?? "year_desc") && + (initialUrlState?.dLanguageId ?? "") === dLanguageId && + (initialUrlState?.dMachinetypeId ?? "") === dMachinetypeId && + (initialUrlState?.filetypeId ?? "") === filetypeId && + (initialUrlState?.schemetypeId ?? "") === schemetypeId && + (initialUrlState?.sourcetypeId ?? "") === sourcetypeId && + (initialUrlState?.casetypeId ?? "") === casetypeId && + (!!initialUrlState?.isDemo === isDemo) + ) { + updateUrl(page); + return; + } + updateUrl(page); + fetchData(q, page); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setPage(1); + updateUrl(1); + fetchData(q, 1); + } + + // Load filter option lists on mount + useEffect(() => { + async function loadLists() { + try { + const [l, m, ft, sc, so, ca] = await Promise.all([ + fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()), + fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()), + fetch("/api/zxdb/filetypes", { cache: "force-cache" }).then((r) => r.json()), + fetch("/api/zxdb/schemetypes", { cache: "force-cache" }).then((r) => r.json()), + fetch("/api/zxdb/sourcetypes", { cache: "force-cache" }).then((r) => r.json()), + fetch("/api/zxdb/casetypes", { cache: "force-cache" }).then((r) => r.json()), + ]); + setLangs(l.items ?? []); + setMachines(m.items ?? []); + setFiletypes(ft.items ?? []); + setSchemes(sc.items ?? []); + setSources(so.items ?? []); + setCases(ca.items ?? []); + } catch { + // ignore + } + } + loadLists(); + }, []); + + 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 (year) params.set("year", year); + if (sort) params.set("sort", sort); + if (dLanguageId) params.set("dLanguageId", dLanguageId); + if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); + if (filetypeId) params.set("filetypeId", filetypeId); + if (schemetypeId) params.set("schemetypeId", schemetypeId); + if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); + if (casetypeId) params.set("casetypeId", casetypeId); + if (isDemo) params.set("isDemo", "1"); + return `/zxdb/releases?${params.toString()}`; + }, [q, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); + + 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 (year) params.set("year", year); + if (sort) params.set("sort", sort); + if (dLanguageId) params.set("dLanguageId", dLanguageId); + if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); + if (filetypeId) params.set("filetypeId", filetypeId); + if (schemetypeId) params.set("schemetypeId", schemetypeId); + if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); + if (casetypeId) params.set("casetypeId", casetypeId); + if (isDemo) params.set("isDemo", "1"); + return `/zxdb/releases?${params.toString()}`; + }, [q, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); + + return ( +
+

Releases

+
+
+ setQ(e.target.value)} + /> +
+
+ +
+
+ { setYear(e.target.value); setPage(1); }} + /> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ { setIsDemo(e.target.checked); setPage(1); }} /> + +
+
+ +
+ {loading && ( +
Loading...
+ )} +
+ +
+ {data && data.items.length === 0 && !loading && ( +
No results.
+ )} + {data && data.items.length > 0 && ( +
+ + + + + + + + + + + {data.items.map((it) => ( + + + + + + + ))} + +
Entry IDTitleRelease #Year
{it.entryId} + {it.entryTitle} + #{it.releaseSeq}{it.year ?? -}
+
+ )} +
+ +
+ + Page {data?.page ?? 1} / {totalPages} + +
+ { + if (!data || data.page <= 1) return; + e.preventDefault(); + setPage((p) => Math.max(1, p - 1)); + }} + > + Prev + + = 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 + +
+
+
+ ); +} diff --git a/src/app/zxdb/releases/page.tsx b/src/app/zxdb/releases/page.tsx new file mode 100644 index 0000000..da9fed3 --- /dev/null +++ b/src/app/zxdb/releases/page.tsx @@ -0,0 +1,41 @@ +import ReleasesExplorer from "./ReleasesExplorer"; +import { searchReleases } from "@/server/repo/zxdb"; + +export const metadata = { + title: "ZXDB Releases", +}; + +export const dynamic = "force-dynamic"; + +export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const sp = await searchParams; + const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); + const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; + const yearStr = (Array.isArray(sp.year) ? sp.year[0] : sp.year) ?? ""; + const year = yearStr ? Number(yearStr) : undefined; + const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) as any) ?? "year_desc"; + const dLanguageId = (Array.isArray(sp.dLanguageId) ? sp.dLanguageId[0] : sp.dLanguageId) ?? ""; + const dMachinetypeIdStr = (Array.isArray(sp.dMachinetypeId) ? sp.dMachinetypeId[0] : sp.dMachinetypeId) ?? ""; + const dMachinetypeId = dMachinetypeIdStr ? Number(dMachinetypeIdStr) : undefined; + const filetypeIdStr = (Array.isArray(sp.filetypeId) ? sp.filetypeId[0] : sp.filetypeId) ?? ""; + const filetypeId = filetypeIdStr ? Number(filetypeIdStr) : undefined; + const schemetypeId = (Array.isArray(sp.schemetypeId) ? sp.schemetypeId[0] : sp.schemetypeId) ?? ""; + const sourcetypeId = (Array.isArray(sp.sourcetypeId) ? sp.sourcetypeId[0] : sp.sourcetypeId) ?? ""; + const casetypeId = (Array.isArray(sp.casetypeId) ? sp.casetypeId[0] : sp.casetypeId) ?? ""; + const isDemoStr = (Array.isArray(sp.isDemo) ? sp.isDemo[0] : sp.isDemo) ?? ""; + const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined; + + const [initial] = 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 }), + ]); + + // Ensure the object passed to a Client Component is a plain JSON value + const initialPlain = JSON.parse(JSON.stringify(initial)); + + return ( + + ); +} diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index 8594ff5..6338d49 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -1,4 +1,5 @@ -import { and, desc, eq, like, sql } from "drizzle-orm"; +import { and, desc, eq, like, sql, asc } from "drizzle-orm"; +import { alias } from "drizzle-orm/mysql-core"; import { db } from "@/server/db"; import { entries, @@ -13,10 +14,12 @@ import { filetypes, releases, downloads, - releasetypes, schemetypes, sourcetypes, casetypes, + availabletypes, + currencies, + roletypes, } from "@/server/schema/zxdb"; export interface SearchParams { @@ -289,19 +292,9 @@ export async function getEntryById(id: number): Promise { releaseRows = (await db .select({ releaseSeq: releases.releaseSeq, - releasetypeId: releases.releasetypeId, - releasetypeName: releasetypes.name, - languageId: releases.languageId, - languageName: languages.name, - machinetypeId: releases.machinetypeId, - machinetypeName: machinetypes.name, year: releases.releaseYear, - comments: releases.comments, }) .from(releases) - .leftJoin(releasetypes, eq(releasetypes.id as any, releases.releasetypeId as any)) - .leftJoin(languages, eq(languages.id as any, releases.languageId as any)) - .leftJoin(machinetypes, eq(machinetypes.id as any, releases.machinetypeId as any)) .where(eq(releases.entryId as any, id as any))) as any; } catch { releaseRows = []; @@ -359,11 +352,11 @@ export async function getEntryById(id: number): Promise { // that appears in downloads but has no corresponding releases row. const releasesData = releaseRows.map((r: any) => ({ releaseSeq: Number(r.releaseSeq), - type: { id: (r.releasetypeId as any) ?? null, name: (r.releasetypeName as any) ?? null }, - language: { id: (r.languageId as any) ?? null, name: (r.languageName as any) ?? null }, - machinetype: { id: (r.machinetypeId as any) ?? null, name: (r.machinetypeName as any) ?? null }, + type: { id: null, name: null }, + language: { id: null, name: null }, + machinetype: { id: null, name: null }, year: (r.year as any) ?? null, - comments: (r.comments as any) ?? null, + comments: null, downloads: (downloadsBySeq.get(Number(r.releaseSeq)) ?? []).map((d: any) => ({ id: d.id, link: d.link, @@ -634,6 +627,9 @@ export async function listMachinetypes() { return db.select().from(machinetypes).orderBy(machinetypes.name); } +// Note: ZXDB structure in this project does not include a `releasetypes` table. +// Do not attempt to query it here. + // Search with pagination for lookups export interface SimpleSearchParams { q?: string; @@ -967,3 +963,150 @@ export async function getEntryFacets(params: SearchParams): Promise machinetypes: (mtRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id), }; } + +// ----- Releases search (browser) ----- + +export interface ReleaseSearchParams { + q?: string; // match entry title via helper search + page?: number; + pageSize?: number; + year?: number; + sort?: "year_desc" | "year_asc" | "title" | "entry_id_desc"; + // Optional download-based filters (matched via EXISTS on downloads) + dLanguageId?: string; // downloads.language_id + dMachinetypeId?: number; // downloads.machinetype_id + filetypeId?: number; // downloads.filetype_id + schemetypeId?: string; // downloads.schemetype_id + sourcetypeId?: string; // downloads.sourcetype_id + casetypeId?: string; // downloads.casetype_id + isDemo?: boolean; // downloads.is_demo +} + +export interface ReleaseListItem { + entryId: number; + releaseSeq: number; + entryTitle: string; + year: number | null; +} + +export async function searchReleases(params: ReleaseSearchParams): Promise> { + const q = (params.q ?? "").trim(); + const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); + const page = Math.max(1, params.page ?? 1); + const offset = (page - 1) * pageSize; + + // Build WHERE conditions in Drizzle QB + const wherePartsQB: any[] = []; + if (q) { + const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; + wherePartsQB.push(sql`${releases.entryId} in (select ${searchByTitles.entryId} from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`); + } + if (params.year != null) { + wherePartsQB.push(eq(releases.releaseYear as any, params.year as any)); + } + + // Optional filters via downloads table: use EXISTS for performance and correctness + // IMPORTANT: when hand-writing SQL with an aliased table, we must render + // "from downloads as d" explicitly; using only the alias identifier ("d") + // would produce "from `d`" which MySQL interprets as a literal table. + const dlConds: any[] = []; + if (params.dLanguageId) dlConds.push(sql`d.language_id = ${params.dLanguageId}`); + if (params.dMachinetypeId != null) dlConds.push(sql`d.machinetype_id = ${params.dMachinetypeId}`); + 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.sourcetypeId) dlConds.push(sql`d.sourcetype_id = ${params.sourcetypeId}`); + if (params.casetypeId) dlConds.push(sql`d.casetype_id = ${params.casetypeId}`); + if (params.isDemo != null) dlConds.push(sql`d.is_demo = ${params.isDemo ? 1 : 0}`); + + if (dlConds.length) { + const baseConds = [ + sql`d.entry_id = ${releases.entryId}`, + sql`d.release_seq = ${releases.releaseSeq}`, + ...dlConds, + ]; + wherePartsQB.push( + sql`exists (select 1 from ${downloads} as d where ${sql.join(baseConds as any, sql` and `)})` + ); + } + const whereExpr = wherePartsQB.length ? and(...(wherePartsQB as any)) : undefined; + + // Count total + const countRows = (await db + .select({ total: sql`count(*)` }) + .from(releases) + .where(whereExpr as any)) as unknown as { total: number }[]; + const total = Number(countRows?.[0]?.total ?? 0); + + // Rows via Drizzle QB to avoid tuple/field leakage + const orderByParts: any[] = []; + switch (params.sort) { + case "year_asc": + orderByParts.push(asc(releases.releaseYear as any), asc(releases.entryId as any), asc(releases.releaseSeq as any)); + break; + case "title": + orderByParts.push(asc(entries.title as any), desc(releases.releaseYear as any), asc(releases.releaseSeq as any)); + break; + case "entry_id_desc": + orderByParts.push(desc(releases.entryId as any), desc(releases.releaseSeq as any)); + break; + case "year_desc": + default: + orderByParts.push(desc(releases.releaseYear as any), desc(releases.entryId as any), desc(releases.releaseSeq as any)); + break; + } + + const rowsQB = await db + .select({ + entryId: releases.entryId, + releaseSeq: releases.releaseSeq, + entryTitle: entries.title, + year: releases.releaseYear, + }) + .from(releases) + .leftJoin(entries, eq(entries.id as any, releases.entryId as any)) + .where(whereExpr as any) + .orderBy(...(orderByParts as any)) + .limit(pageSize) + .offset(offset); + + // Ensure plain primitives + const items: ReleaseListItem[] = rowsQB.map((r: any) => ({ + entryId: Number(r.entryId), + releaseSeq: Number(r.releaseSeq), + entryTitle: r.entryTitle ?? "", + year: r.year != null ? Number(r.year) : null, + })); + + return { items, page, pageSize, total }; +} + +// ----- Download/lookups simple lists ----- +export async function listFiletypes() { + return db.select().from(filetypes).orderBy(filetypes.name); +} +export async function listSchemetypes() { + return db.select().from(schemetypes).orderBy(schemetypes.name); +} +export async function listSourcetypes() { + return db.select().from(sourcetypes).orderBy(sourcetypes.name); +} +export async function listCasetypes() { + return db.select().from(casetypes).orderBy(casetypes.name); +} + +// Newly exposed lookups +export async function listAvailabletypes() { + return db.select().from(availabletypes).orderBy(availabletypes.name); +} + +export async function listCurrencies() { + // Preserve full fields for UI needs + return db + .select({ id: currencies.id, name: currencies.name, symbol: currencies.symbol, prefix: currencies.prefix }) + .from(currencies) + .orderBy(currencies.name); +} + +export async function listRoletypes() { + return db.select().from(roletypes).orderBy(roletypes.name); +} diff --git a/src/server/schema/zxdb.ts b/src/server/schema/zxdb.ts index 222f71d..382b086 100644 --- a/src/server/schema/zxdb.ts +++ b/src/server/schema/zxdb.ts @@ -1,4 +1,4 @@ -import { mysqlTable, int, varchar, tinyint, char, smallint } from "drizzle-orm/mysql-core"; +import { mysqlTable, int, varchar, tinyint, char, smallint, decimal } from "drizzle-orm/mysql-core"; // Minimal subset needed for browsing/searching export const entries = mysqlTable("entries", { @@ -80,6 +80,21 @@ export const genretypes = mysqlTable("genretypes", { name: varchar("text", { length: 50 }).notNull(), }); +// Additional lookups +export const availabletypes = mysqlTable("availabletypes", { + id: char("id", { length: 1 }).notNull().primaryKey(), + // DB column `text` + name: varchar("text", { length: 50 }).notNull(), +}); + +export const currencies = mysqlTable("currencies", { + id: char("id", { length: 3 }).notNull().primaryKey(), + name: varchar("name", { length: 50 }).notNull(), + symbol: varchar("symbol", { length: 20 }), + // Stored as tinyint(1) 0/1 + prefix: tinyint("prefix").notNull(), +}); + // ----- Files and Filetypes (for downloads/assets) ----- export const filetypes = mysqlTable("filetypes", { id: tinyint("id").notNull().primaryKey(), @@ -100,14 +115,6 @@ export const files = mysqlTable("files", { comments: varchar("comments", { length: 250 }), }); -// ----- Releases / Downloads (linked assets per release) ----- -// Lookups used by releases/downloads -export const releasetypes = mysqlTable("releasetypes", { - id: char("id", { length: 1 }).notNull().primaryKey(), - // column name in DB is `text` - name: varchar("text", { length: 50 }).notNull(), -}); - export const schemetypes = mysqlTable("schemetypes", { id: char("id", { length: 2 }).notNull().primaryKey(), name: varchar("text", { length: 50 }).notNull(), @@ -123,6 +130,11 @@ export const casetypes = mysqlTable("casetypes", { name: varchar("text", { length: 50 }).notNull(), }); +export const roletypes = mysqlTable("roletypes", { + id: char("id", { length: 1 }).notNull().primaryKey(), + name: varchar("text", { length: 50 }).notNull(), +}); + export const hosts = mysqlTable("hosts", { id: tinyint("id").notNull().primaryKey(), title: varchar("title", { length: 150 }).notNull(), @@ -135,13 +147,17 @@ export const hosts = mysqlTable("hosts", { export const releases = mysqlTable("releases", { entryId: int("entry_id").notNull(), releaseSeq: smallint("release_seq").notNull(), - releasetypeId: char("releasetype_id", { length: 1 }), - languageId: char("language_id", { length: 2 }), - machinetypeId: tinyint("machinetype_id"), - labelId: int("label_id"), // developer - publisherId: int("publisher_label_id"), releaseYear: smallint("release_year"), - comments: varchar("comments", { length: 250 }), + releaseMonth: smallint("release_month"), + releaseDay: smallint("release_day"), + currencyId: char("currency_id", { length: 3 }), + releasePrice: decimal("release_price", { precision: 9, scale: 2 }), + budgetPrice: decimal("budget_price", { precision: 9, scale: 2 }), + microdrivePrice: decimal("microdrive_price", { precision: 9, scale: 2 }), + diskPrice: decimal("disk_price", { precision: 9, scale: 2 }), + cartridgePrice: decimal("cartridge_price", { precision: 9, scale: 2 }), + bookIsbn: varchar("book_isbn", { length: 50 }), + bookPages: smallint("book_pages"), }); // Downloads are linked to a release via (entry_id, release_seq) @@ -167,3 +183,10 @@ export const downloads = mysqlTable("downloads", { releaseYear: smallint("release_year"), comments: varchar("comments", { length: 250 }), }); + +// Roles relation (composite PK in DB) +export const roles = mysqlTable("roles", { + entryId: int("entry_id").notNull(), + labelId: int("label_id").notNull(), + roletypeId: char("roletype_id", { length: 1 }).notNull(), +});