ZXDB: Releases browser filters, schema lists, and fixes
- UI: Add /zxdb hub cards for Entries and Releases; implement Releases browser
with URL‑synced filters (q, year, sort, DL language/machine, file/scheme/source/case, demo)
and a paginated table (Entry ID, Title, Release #, Year).
- API: Add GET /api/zxdb/releases/search (Zod‑validated, Node runtime) supporting
title, year, sort, and downloads‑based filters; return paged JSON.
- Repo: Rewrite searchReleases to Drizzle QB; correct ORDER BY on releases.release_year;
implement EXISTS on downloads using explicit "from downloads as d"; return JSON‑safe rows.
- Schema: Align Drizzle models with ZXDB for releases/downloads; add lookups
availabletypes, currencies, roletypes, and roles relation.
- API (lookups): Add GET /api/zxdb/{availabletypes,currencies,roletypes} for dropdowns.
- Stability: JSON‑clone SSR payloads before passing to Client Components to avoid
RowDataPacket serialization errors.
Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
@@ -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
|
- Schema: add Drizzle models for availabletypes, currencies, roletypes, issues, magazines, role relations
|
||||||
are displayed even when there are no matching rows in
|
- Repo: add list/search functions for new lookups (availabletypes, currencies, roletypes)
|
||||||
`releases` for a given entry. Group by `release_seq`,
|
- API: expose /api/zxdb/{availabletypes,currencies,roletypes} list endpoints
|
||||||
attach downloads, and sort groups for stable order.
|
- 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`
|
Signed-off-by: Junie@HOSTNAME
|
||||||
exist for the entry but `releases` is empty, resulting in
|
|
||||||
no downloads shown in the UI.
|
|
||||||
|
|
||||||
Signed-off-by: Junie@devbox
|
|
||||||
|
|||||||
10
src/app/api/zxdb/availabletypes/route.ts
Normal file
10
src/app/api/zxdb/availabletypes/route.ts
Normal file
@@ -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";
|
||||||
10
src/app/api/zxdb/casetypes/route.ts
Normal file
10
src/app/api/zxdb/casetypes/route.ts
Normal file
@@ -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";
|
||||||
10
src/app/api/zxdb/currencies/route.ts
Normal file
10
src/app/api/zxdb/currencies/route.ts
Normal file
@@ -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";
|
||||||
10
src/app/api/zxdb/filetypes/route.ts
Normal file
10
src/app/api/zxdb/filetypes/route.ts
Normal file
@@ -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";
|
||||||
48
src/app/api/zxdb/releases/search/route.ts
Normal file
48
src/app/api/zxdb/releases/search/route.ts
Normal file
@@ -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";
|
||||||
10
src/app/api/zxdb/roletypes/route.ts
Normal file
10
src/app/api/zxdb/roletypes/route.ts
Normal file
@@ -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";
|
||||||
10
src/app/api/zxdb/schemetypes/route.ts
Normal file
10
src/app/api/zxdb/schemetypes/route.ts
Normal file
@@ -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";
|
||||||
10
src/app/api/zxdb/sourcetypes/route.ts
Normal file
10
src/app/api/zxdb/sourcetypes/route.ts
Normal file
@@ -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";
|
||||||
324
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
324
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
@@ -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<T> = {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntriesExplorer({
|
||||||
|
initial,
|
||||||
|
initialGenres,
|
||||||
|
initialLanguages,
|
||||||
|
initialMachines,
|
||||||
|
initialUrlState,
|
||||||
|
}: {
|
||||||
|
initial?: Paged<Item>;
|
||||||
|
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<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 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<Item> = 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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-3">Entries</h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Search titles..."
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={genreId as any} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||||
|
<option value="">Genre</option>
|
||||||
|
{genres.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={languageId as any} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">Language</option>
|
||||||
|
{languages.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={machinetypeId as any} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||||
|
<option value="">Machine</option>
|
||||||
|
{machines.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as any); setPage(1); }}>
|
||||||
|
<option value="title">Sort: Title</option>
|
||||||
|
<option value="id_desc">Sort: Newest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="col-auto text-secondary">Loading...</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{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}}>Machine</th>
|
||||||
|
<th style={{width: 120}}>Language</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td>{it.id}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link>
|
||||||
|
</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 className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/zxdb/entries/page.tsx
Normal file
43
src/app/zxdb/entries/page.tsx
Normal file
@@ -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 (
|
||||||
|
<EntriesExplorer
|
||||||
|
initial={initial as any}
|
||||||
|
initialGenres={genres as any}
|
||||||
|
initialLanguages={langs as any}
|
||||||
|
initialMachines={machines as any}
|
||||||
|
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,60 @@
|
|||||||
import ZxdbExplorer from "./ZxdbExplorer";
|
import Link from "next/link";
|
||||||
import { searchEntries, listGenres, listLanguages, listMachinetypes } from "@/server/repo/zxdb";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "ZXDB Explorer",
|
title: "ZXDB Explorer",
|
||||||
};
|
};
|
||||||
|
|
||||||
// This page depends on searchParams (?page=, filters in future). Force dynamic
|
export const revalidate = 3600;
|
||||||
// rendering so ISR doesn’t cache a single HTML for all query strings.
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
export default async function Page() {
|
||||||
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(),
|
|
||||||
]);
|
|
||||||
return (
|
return (
|
||||||
<ZxdbExplorer
|
<div>
|
||||||
initial={initial as any}
|
<h1 className="mb-3">ZXDB Explorer</h1>
|
||||||
initialGenres={genres as any}
|
<p className="text-secondary">Choose what you want to explore.</p>
|
||||||
initialLanguages={langs as any}
|
|
||||||
initialMachines={machines as any}
|
<div className="row g-3">
|
||||||
/>
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<Link href="/zxdb/entries" className="text-decoration-none">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body d-flex align-items-center">
|
||||||
|
<div className="me-3" aria-hidden>
|
||||||
|
<span className="bi bi-collection" style={{ fontSize: 28 }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">Entries</h5>
|
||||||
|
<div className="card-text text-secondary">Browse software entries with filters</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<Link href="/zxdb/releases" className="text-decoration-none">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body d-flex align-items-center">
|
||||||
|
<div className="me-3" aria-hidden>
|
||||||
|
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">Releases</h5>
|
||||||
|
<div className="card-text text-secondary">Drill into releases and downloads</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h2 className="h5 mb-2">Categories</h2>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
366
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
366
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
@@ -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<T> = {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReleasesExplorer({
|
||||||
|
initial,
|
||||||
|
initialUrlState,
|
||||||
|
}: {
|
||||||
|
initial?: Paged<Item>;
|
||||||
|
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<Paged<Item> | null>(initial ?? null);
|
||||||
|
const [year, setYear] = useState<string>(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<string>(initialUrlState?.dLanguageId ?? "");
|
||||||
|
const [dMachinetypeId, setDMachinetypeId] = useState<string>(initialUrlState?.dMachinetypeId ?? "");
|
||||||
|
const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? "");
|
||||||
|
const [schemetypeId, setSchemetypeId] = useState<string>(initialUrlState?.schemetypeId ?? "");
|
||||||
|
const [sourcetypeId, setSourcetypeId] = useState<string>(initialUrlState?.sourcetypeId ?? "");
|
||||||
|
const [casetypeId, setCasetypeId] = useState<string>(initialUrlState?.casetypeId ?? "");
|
||||||
|
const [isDemo, setIsDemo] = useState<boolean>(!!(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<Item> = 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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-3">Releases</h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Filter by entry title..."
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Year"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">DL Language</option>
|
||||||
|
{langs.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">DL Machine</option>
|
||||||
|
{machines.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">File type</option>
|
||||||
|
{filetypes.map((ft) => (
|
||||||
|
<option key={ft.id} value={ft.id}>{ft.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">Scheme</option>
|
||||||
|
{schemes.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">Source</option>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">Case</option>
|
||||||
|
{cases.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto form-check ms-2">
|
||||||
|
<input id="demoCheck" className="form-check-input" type="checkbox" checked={isDemo} onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }} />
|
||||||
|
<label className="form-check-label" htmlFor="demoCheck">Demo only</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as any); setPage(1); }}>
|
||||||
|
<option value="year_desc">Sort: Newest</option>
|
||||||
|
<option value="year_asc">Sort: Oldest</option>
|
||||||
|
<option value="title">Sort: Title</option>
|
||||||
|
<option value="entry_id_desc">Sort: Entry ID</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="col-auto text-secondary">Loading...</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{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}}>Entry ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{width: 110}}>Release #</th>
|
||||||
|
<th style={{width: 100}}>Year</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((it) => (
|
||||||
|
<tr key={`${it.entryId}-${it.releaseSeq}`}>
|
||||||
|
<td>{it.entryId}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/entries/${it.entryId}`}>{it.entryTitle}</Link>
|
||||||
|
</td>
|
||||||
|
<td>#{it.releaseSeq}</td>
|
||||||
|
<td>{it.year ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/zxdb/releases/page.tsx
Normal file
41
src/app/zxdb/releases/page.tsx
Normal file
@@ -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 (
|
||||||
|
<ReleasesExplorer
|
||||||
|
initial={initialPlain as any}
|
||||||
|
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { db } from "@/server/db";
|
||||||
import {
|
import {
|
||||||
entries,
|
entries,
|
||||||
@@ -13,10 +14,12 @@ import {
|
|||||||
filetypes,
|
filetypes,
|
||||||
releases,
|
releases,
|
||||||
downloads,
|
downloads,
|
||||||
releasetypes,
|
|
||||||
schemetypes,
|
schemetypes,
|
||||||
sourcetypes,
|
sourcetypes,
|
||||||
casetypes,
|
casetypes,
|
||||||
|
availabletypes,
|
||||||
|
currencies,
|
||||||
|
roletypes,
|
||||||
} from "@/server/schema/zxdb";
|
} from "@/server/schema/zxdb";
|
||||||
|
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
@@ -289,19 +292,9 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
releaseRows = (await db
|
releaseRows = (await db
|
||||||
.select({
|
.select({
|
||||||
releaseSeq: releases.releaseSeq,
|
releaseSeq: releases.releaseSeq,
|
||||||
releasetypeId: releases.releasetypeId,
|
|
||||||
releasetypeName: releasetypes.name,
|
|
||||||
languageId: releases.languageId,
|
|
||||||
languageName: languages.name,
|
|
||||||
machinetypeId: releases.machinetypeId,
|
|
||||||
machinetypeName: machinetypes.name,
|
|
||||||
year: releases.releaseYear,
|
year: releases.releaseYear,
|
||||||
comments: releases.comments,
|
|
||||||
})
|
})
|
||||||
.from(releases)
|
.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;
|
.where(eq(releases.entryId as any, id as any))) as any;
|
||||||
} catch {
|
} catch {
|
||||||
releaseRows = [];
|
releaseRows = [];
|
||||||
@@ -359,11 +352,11 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
// that appears in downloads but has no corresponding releases row.
|
// that appears in downloads but has no corresponding releases row.
|
||||||
const releasesData = releaseRows.map((r: any) => ({
|
const releasesData = releaseRows.map((r: any) => ({
|
||||||
releaseSeq: Number(r.releaseSeq),
|
releaseSeq: Number(r.releaseSeq),
|
||||||
type: { id: (r.releasetypeId as any) ?? null, name: (r.releasetypeName as any) ?? null },
|
type: { id: null, name: null },
|
||||||
language: { id: (r.languageId as any) ?? null, name: (r.languageName as any) ?? null },
|
language: { id: null, name: null },
|
||||||
machinetype: { id: (r.machinetypeId as any) ?? null, name: (r.machinetypeName as any) ?? null },
|
machinetype: { id: null, name: null },
|
||||||
year: (r.year as any) ?? null,
|
year: (r.year as any) ?? null,
|
||||||
comments: (r.comments as any) ?? null,
|
comments: null,
|
||||||
downloads: (downloadsBySeq.get(Number(r.releaseSeq)) ?? []).map((d: any) => ({
|
downloads: (downloadsBySeq.get(Number(r.releaseSeq)) ?? []).map((d: any) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
link: d.link,
|
link: d.link,
|
||||||
@@ -634,6 +627,9 @@ export async function listMachinetypes() {
|
|||||||
return db.select().from(machinetypes).orderBy(machinetypes.name);
|
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
|
// Search with pagination for lookups
|
||||||
export interface SimpleSearchParams {
|
export interface SimpleSearchParams {
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -967,3 +963,150 @@ export async function getEntryFacets(params: SearchParams): Promise<EntryFacets>
|
|||||||
machinetypes: (mtRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
|
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<PagedResult<ReleaseListItem>> {
|
||||||
|
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<number>`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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// Minimal subset needed for browsing/searching
|
||||||
export const entries = mysqlTable("entries", {
|
export const entries = mysqlTable("entries", {
|
||||||
@@ -80,6 +80,21 @@ export const genretypes = mysqlTable("genretypes", {
|
|||||||
name: varchar("text", { length: 50 }).notNull(),
|
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) -----
|
// ----- Files and Filetypes (for downloads/assets) -----
|
||||||
export const filetypes = mysqlTable("filetypes", {
|
export const filetypes = mysqlTable("filetypes", {
|
||||||
id: tinyint("id").notNull().primaryKey(),
|
id: tinyint("id").notNull().primaryKey(),
|
||||||
@@ -100,14 +115,6 @@ export const files = mysqlTable("files", {
|
|||||||
comments: varchar("comments", { length: 250 }),
|
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", {
|
export const schemetypes = mysqlTable("schemetypes", {
|
||||||
id: char("id", { length: 2 }).notNull().primaryKey(),
|
id: char("id", { length: 2 }).notNull().primaryKey(),
|
||||||
name: varchar("text", { length: 50 }).notNull(),
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
@@ -123,6 +130,11 @@ export const casetypes = mysqlTable("casetypes", {
|
|||||||
name: varchar("text", { length: 50 }).notNull(),
|
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", {
|
export const hosts = mysqlTable("hosts", {
|
||||||
id: tinyint("id").notNull().primaryKey(),
|
id: tinyint("id").notNull().primaryKey(),
|
||||||
title: varchar("title", { length: 150 }).notNull(),
|
title: varchar("title", { length: 150 }).notNull(),
|
||||||
@@ -135,13 +147,17 @@ export const hosts = mysqlTable("hosts", {
|
|||||||
export const releases = mysqlTable("releases", {
|
export const releases = mysqlTable("releases", {
|
||||||
entryId: int("entry_id").notNull(),
|
entryId: int("entry_id").notNull(),
|
||||||
releaseSeq: smallint("release_seq").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"),
|
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)
|
// Downloads are linked to a release via (entry_id, release_seq)
|
||||||
@@ -167,3 +183,10 @@ export const downloads = mysqlTable("downloads", {
|
|||||||
releaseYear: smallint("release_year"),
|
releaseYear: smallint("release_year"),
|
||||||
comments: varchar("comments", { length: 250 }),
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user