feat: integrate ZXDB with Drizzle + deep explorer UI; fix Next 15 dynamic params; align ZXDB schema columns
End-to-end ZXDB integration with environment validation, Drizzle ORM MySQL
setup, typed repositories, Zod-validated API endpoints, and a deep, cross‑
linked Explorer UI under `/zxdb`. Also update dynamic route pages to the
Next.js 15 async `params` API and align ZXDB lookup table columns (`text` vs
`name`).
Summary
- Add t3.gg-style Zod environment validation and typed `env` access
- Wire Drizzle ORM to ZXDB (mysql2 pool, singleton) and minimal schemas
- Implement repositories for search, entry details, label browsing, and
category listings (genres, languages, machinetypes)
- Expose a set of Next.js API routes with strict Zod validation
- Build the ZXDB Explorer UI with search, filters, sorting, deep links, and
entity pages (entries, labels, genres, languages, machinetypes)
- Fix Next 15 “sync-dynamic-apis” warning by awaiting dynamic `params`
- Correct ZXDB lookup model columns to use `text` (aliased as `name`)
Details
Env & DB
- example.env: document `ZXDB_URL` with readonly role notes
- src/env.ts: Zod schema validates `ZXDB_URL` as `mysql://…`; fails fast on
invalid env
- src/server/db.ts: create mysql2 pool from `ZXDB_URL`; export Drizzle instance
- drizzle.config.ts: drizzle-kit configuration (schema path, mysql2 driver)
Schema (Drizzle)
- src/server/schema/zxdb.ts:
- entries: id, title, is_xrated, machinetype_id, language_id, genretype_id
- helper tables: search_by_titles, search_by_names, search_by_authors,
search_by_publishers
- relations: authors, publishers
- lookups: labels, languages, machinetypes, genretypes
- map lookup display columns from DB `text` to model property `name`
Repository
- src/server/repo/zxdb.ts:
- searchEntries: title search via helper table with filters (genre, language,
machine), sorting (title, id_desc), and pagination
- getEntryById: join lookups and aggregate authors/publishers
- Label flows: searchLabels (helper table), getLabelById, getLabelAuthoredEntries,
getLabelPublishedEntries
- Category lists: listGenres, listLanguages, listMachinetypes
- Category pages: entriesByGenre, entriesByLanguage, entriesByMachinetype
API (Node runtime, Zod validation)
- GET /api/zxdb/search: search entries with filters and sorting
- GET /api/zxdb/entries/[id]: fetch entry detail
- GET /api/zxdb/labels/search, GET /api/zxdb/labels/[id]: label search and detail
- GET /api/zxdb/genres, /api/zxdb/genres/[id]
- GET /api/zxdb/languages, /api/zxdb/languages/[id]
- GET /api/zxdb/machinetypes, /api/zxdb/machinetypes/[id]
UI (App Router)
- /zxdb: Explorer page with search box, filters (genre, language, machine), sort,
paginated results & links to entries; quick browse links to hubs
- /zxdb/entries/[id]: entry detail client component shows title, badges
(genre/lang/machine), authors and publishers with cross-links
- /zxdb/labels (+ /[id]): search & label detail with "Authored" and "Published"
tabs, paginated lists linking to entries
- /zxdb/genres, /zxdb/languages, /zxdb/machinetypes and their /[id] detail pages
listing paginated entries and deep links
- Navbar: add ZXDB link
Next 15 dynamic routes
- Convert Server Component dynamic pages to await `params` before accessing
properties:
- /zxdb/entries/[id]/page.tsx
- /zxdb/labels/[id]/page.tsx
- /zxdb/genres/[id]/page.tsx
- /zxdb/languages/[id]/page.tsx
- /registers/[hex]/page.tsx (Registers section)
- /api/zxdb/entries/[id]/route.ts: await `ctx.params` before validation
ZXDB schema column alignment
- languages, machinetypes, genretypes tables use `text` for display columns;
models now map to `name` to preserve API/UI contracts and avoid MySQL 1054
errors in joins (e.g., entry detail endpoint).
Notes
- Ensure ZXDB helper tables are created (ZXDB/scripts/ZXDB_help_search.sql)
— required for fast title/name searches and author/publisher lookups.
- Pagination defaults to 20 (max 100). No `select *` used in queries.
- API responses are `cache: no-store` for now; can be tuned later.
Deferred (future work)
- Facet counts in the Explorer sidebar
- Breadcrumbs and additional a11y polish
- Media assets and download links per release
Signed-off-by: Junie@lucy.xalior.com
Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
109
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
109
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||
type EntryDetail = {
|
||||
id: number;
|
||||
title: string;
|
||||
isXrated: number;
|
||||
machinetype: { id: number | null; name: string | null };
|
||||
language: { id: string | null; name: string | null };
|
||||
genre: { id: number | null; name: string | null };
|
||||
authors: Label[];
|
||||
publishers: Label[];
|
||||
};
|
||||
|
||||
export default function EntryDetailClient({ id }: { id: number }) {
|
||||
const [data, setData] = useState<EntryDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/zxdb/entries/${id}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const json: EntryDetail = await res.json();
|
||||
if (!aborted) setData(json);
|
||||
} catch (e: any) {
|
||||
if (!aborted) setError(e?.message ?? "Failed to load");
|
||||
} finally {
|
||||
if (!aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error) return <div className="alert alert-danger">{error}</div>;
|
||||
if (!data) return <div className="alert alert-warning">Not found</div>;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h1 className="mb-0">{data.title}</h1>
|
||||
{data.genre.name && (
|
||||
<a className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
|
||||
{data.genre.name}
|
||||
</a>
|
||||
)}
|
||||
{data.language.name && (
|
||||
<a className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
|
||||
{data.language.name}
|
||||
</a>
|
||||
)}
|
||||
{data.machinetype.name && (
|
||||
<a className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
|
||||
{data.machinetype.name}
|
||||
</a>
|
||||
)}
|
||||
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="row g-4">
|
||||
<div className="col-lg-6">
|
||||
<h5>Authors</h5>
|
||||
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||
{data.authors.length > 0 && (
|
||||
<ul className="list-unstyled mb-0">
|
||||
{data.authors.map((a) => (
|
||||
<li key={a.id}>
|
||||
<a href={`/zxdb/labels/${a.id}`}>{a.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<h5>Publishers</h5>
|
||||
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||
{data.publishers.length > 0 && (
|
||||
<ul className="list-unstyled mb-0">
|
||||
{data.publishers.map((p) => (
|
||||
<li key={p.id}>
|
||||
<a href={`/zxdb/labels/${p.id}`}>{p.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<a className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</a>
|
||||
<a className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user