23 Commits

Author SHA1 Message Date
279abac91a Link entry_id across UI; surface aliases/webrefs on Entry\n\n- Add EntryLink component for /zxdb/entries/[id]\n- Use EntryLink in Entries, Releases, and Label detail tables\n- Extend Entry detail with Aliases and Web links sections\n- Add Drizzle schema for aliases, webrefs, websites; fetch in repo\n\nSigned-off-by: Junie@lucy\n 2025-12-17 22:32:13 +00:00
2bade1825c Add entry_id relationship links to Entries
- Introduce reusable EntryLink component
- Use EntryLink in Releases and Label detail tables
- Link both ID and title to /zxdb/entries/[id] for consistency

Signed-off-by: Junie@MacOS
2025-12-17 22:30:48 +00:00
07478b280c Fix build errors 2025-12-17 20:22:00 +00:00
89001f53da No explicit any 2025-12-17 20:10:00 +00:00
18cf0cc140 Merge branch 'feat/zxdb' into deploy 2025-12-17 19:55:59 +00:00
53a1821547 Remove topbar nav 2025-12-17 19:54:47 +00:00
24cb74ac14 version bump 2025-12-17 12:41:17 +00:00
363c8bc121 Update docs/ZXDB.md 2025-12-17 12:07:28 +00:00
038c60338b Update docs/ZXDB.md 2025-12-17 12:05:50 +00:00
f563b41792 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
2025-12-16 23:00:38 +00:00
fd4c0f8963 Show downloads even without releases rows
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.

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
2025-12-16 21:47:17 +00:00
285c7da87c Handle missing ZXDB releases/downloads schema gracefully
Prevent runtime crashes when `releases`, `downloads`, or related lookup tables
(`releasetypes`, `schemetypes`, `sourcetypes`, `casetypes`) are absent in the
connected ZXDB MySQL database.

- Repo: gate releases/downloads queries behind a schema capability check using
  `information_schema.tables`; if missing, skip queries and return empty arrays.
- Keeps entry detail page functional on legacy/minimal DB exports while fully
  utilizing rich data when available.

Refs: runtime error "Table 'zxdb.releasetypes' doesn't exist"

Signed-off-by: Junie@quinn
2025-12-16 18:41:14 +00:00
761810901f Bump React 2025-12-14 11:29:34 +00:00
f507d51c61 Bump React 2025-12-14 11:29:16 +00:00
240936a850 Standardize ZXDB UI; add SSR search/tables
Unify the look and feel of all /zxdb pages and minimize client pop-in.

- Make all /zxdb pages full-width to match /explorer
- Convert Languages, Genres, Machine Types, and Labels lists to
  Bootstrap tables with table-striped and table-hover inside
  table-responsive wrappers
- Replace raw FK IDs with linked names via SSR repository joins
- Add scoped search boxes on detail pages (labels, genres, languages,
  machine types) with SSR filtering and pagination that preserves q/tab
- Keep explorer results consistent: show Machine/Language names with
  links, no client lookups required

This improves consistency, readability, and first paint stability across
the ZXDB section while keeping navigation fast and discoverable.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 16:58:50 +00:00
ddbf72ea52 docs: add ZXDB guide; refresh README & AGENTS
Expand and update documentation to reflect the current app (Registers + ZXDB Explorer), with clear setup and usage instructions.

Changes
- README: add project overview including ZXDB Explorer; routes tour; ZXDB setup (DB import, helper search tables, readonly role); environment configuration; selected API endpoints; implementation notes (Next 15 async params, Node runtime for mysql2, SSR/ISR usage); links to AGENTS.md and docs/ZXDB.md.
- docs/ZXDB.md (new): deep-dive guide covering database preparation, helper tables, environment, Explorer UI, API reference under /api/zxdb, performance approach (helper tables, parallel queries, ISR), troubleshooting, and roadmap.
- AGENTS.md: refresh Project Overview/Structure with ZXDB routes and server/client boundaries; document Next.js 15 dynamic params async pattern for pages and API routes; note Drizzle+mysql2, Node runtime, and lookup `text`→`name` mapping; keep commit workflow guidance.
- example.env: add reference to docs/ZXDB.md and clarify mysql:// format and setup pointers.

Notes
- Documentation focuses on the current state of the codebase (what the code does), not a log of agent actions.
- Helper SQL at ZXDB/scripts/ZXDB_help_search.sql is required for performant searches.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 16:17:35 +00:00
3ef3a16bc0 Fix ZXDB pagination counters and navigation
Implement URL-driven pagination and correct total counts across ZXDB:
- Root /zxdb: SSR reads ?page; client syncs to SSR; Prev/Next as Links.
- Sub-index pages (genres, languages, machinetypes): parse ?page on server; use SSR props in clients; Prev/Next via Links.
- Labels browse (/zxdb/labels): dynamic SSR, reads ?q & ?page; typed count(*); client syncs to SSR; Prev/Next preserve q.
- Label detail (/zxdb/labels/[id]): tab-aware Prev/Next Links; counters from server.
- Repo: replace raw counts with typed Drizzle count(*) for reliable totals.

Signed-off-by: Junie <Junie@lucy.xalior.com>
2025-12-12 16:11:12 +00:00
54cfe4f175 perf(zxdb): server-render index pages with ISR and initial data
Why
- Reduce time-to-first-content on ZXDB index pages by eliminating the initial client-side fetch and enabling incremental static regeneration.

What
- Main Explorer (/zxdb):
  - Server-renders first page of results and lookup lists (genres, languages, machinetypes) and passes them as initial props.
  - Keeps client interactivity for subsequent searches/filters.
- Labels index (/zxdb/labels):
  - Server-renders first page of empty search and passes as initial props to skip the first fetch.
- Category lists:
  - Genres (/zxdb/genres), Languages (/zxdb/languages), Machine Types (/zxdb/machinetypes) now server-render their lists and export revalidate=3600.
  - Refactored list components to accept server-provided items; removed on-mount fetching.
- Links & prefetch:
  - Replaced remaining anchors with Next Link to enable prefetch where applicable.

Tech details
- Added revalidate=3600 to the index pages for ISR.
- Updated ZxdbExplorer to accept initial results and initial filter lists; skips first client fetch when initial props are present.
- Updated LabelsSearch to accept initial payload and skip first fetch in default state.
- Updated GenreList, LanguageList, MachineTypeList to be presentational components receiving items from server pages.

Notes
- Low-churn list APIs already emit Cache-Control for CDN; list pages now render instantly from server.
- Further polish (breadcrumbs, facet counts UI) can build on this foundation without reintroducing initial network waits.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 15:31:10 +00:00
ad77b47117 chore: commit pending ZXDB explorer changes prior to index perf work
Context
- Housekeeping commit to capture all current ZXDB Explorer work before index-page performance optimizations.

Includes
- Server-rendered entry detail page with ISR and parallelized DB queries.
- Node runtime for ZXDB API routes and params validation updates for Next 15.
- ZXDB repository extensions (facets, label queries, category queries).
- Cross-linking and Link-based prefetch across ZXDB UI.
- Cache headers on low-churn list APIs.

Notes
- Follow-up commit will focus specifically on speeding up index pages via SSR initial data and ISR.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 15:25:35 +00:00
3fe6f980c6 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
2025-12-12 14:41:19 +00:00
dbbad09b1b chore: ZXDB env validation, MySQL setup, API & UI
This sanity commit wires up the initial ZXDB integration and a minimal UI to explore it.

Key changes:
- Add Zod-based env parsing (`src/env.ts`) validating `ZXDB_URL` as a mysql:// URL (t3.gg style).
- Configure Drizzle ORM with mysql2 connection pool (`src/server/db.ts`) driven by `ZXDB_URL`.
- Define minimal ZXDB schema models (`src/server/schema/zxdb.ts`): `entries` and helper `search_by_titles`.
- Implement repository search with pagination using helper table (`src/server/repo/zxdb.ts`).
- Expose Next.js API route `GET /api/zxdb/search` with Zod query validation and Node runtime (`src/app/api/zxdb/search/route.ts`).
- Create new app section “ZXDB Explorer” at `/zxdb` with search UI, results table, and pagination (`src/app/zxdb/*`).
- Add navbar link to ZXDB (`src/components/Navbar.tsx`).
- Update example.env with readonly-role notes and example `ZXDB_URL`.
- Add drizzle-kit config scaffold (`drizzle.config.ts`).
- Update package.json deps: drizzle-orm, mysql2, zod; devDeps: drizzle-kit. Lockfile updated.
- Extend .gitignore to exclude large ZXDB structure dump.

Notes:
- Ensure ZXDB data and helper tables are loaded (see `ZXDB/scripts/ZXDB_help_search.sql`).
- This commit provides structure-only browsing; future work can enrich schema (authors, labels, publishers) and UI filters.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 14:06:58 +00:00
4222eba8ba Ready to start adding SQL binding 2025-12-12 13:43:30 +00:00
79aabd9b62 Update, before adding massive new feature 2025-12-12 13:28:51 +00:00
69 changed files with 5901 additions and 178 deletions

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ next-env.d.ts
# PNPM build artifacts # PNPM build artifacts
.pnpm .pnpm
.pnpm-store .pnpm-store
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "ZXDB"]
path = ZXDB
url = https://github.com/zxdb/ZXDB

1
.junie/guidelines.md Symbolic link
View File

@@ -0,0 +1 @@
../AGENTS.md

225
.output.txt Normal file
View File

@@ -0,0 +1,225 @@
▲ Next.js 15.5.9 (Turbopack)
- Environments: .env
Creating an optimized production build ...
✓ Finished writing to disk in 48ms
Turbopack build encountered 21 warnings:
./src/scss/nbn.scss
Issue while running loader
SassWarning: 311 repetitive deprecation warnings omitted.
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 0, column 8 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/bootstrap.scss:0:8:
Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
More info and automated migrator: https://sass-lang.com/d/import
0 | @import "mixins/banner";
node_modules/bootstrap/scss/bootstrap.scss 1:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 10, column 29 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:10:29:
Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
Use math.unit instead.
More info and automated migrator: https://sass-lang.com/d/import
10 | @if $prev-num == null or unit($num) == "%" or unit($prev-num) == "%" {
node_modules/bootstrap/scss/_functions.scss 11:30 -assert-ascending()
node_modules/bootstrap/scss/_variables.scss 494:1 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 10, column 50 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:10:50:
Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
Use math.unit instead.
More info and automated migrator: https://sass-lang.com/d/import
10 | @if $prev-num == null or unit($num) == "%" or unit($prev-num) == "%" {
node_modules/bootstrap/scss/_functions.scss 11:51 -assert-ascending()
node_modules/bootstrap/scss/_variables.scss 494:1 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 10, column 8 of file:///Volumes/McFiver/u/GIT/next-explorer/src/scss/nbn.scss:10:8:
Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
More info and automated migrator: https://sass-lang.com/d/import
10 | @import "bootswatch";
src/scss/nbn.scss 11:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 12, column 8 of file:///Volumes/McFiver/u/GIT/next-explorer/src/scss/nbn.scss:12:8:
Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
More info and automated migrator: https://sass-lang.com/d/import
12 | @import "explorer";
src/scss/nbn.scss 13:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 176, column 10 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:176:10:
The Sass if() syntax is deprecated in favor of the modern CSS syntax.
Suggestion: if(sass($l1 > $l2): divide($l1 + 0.05, $l2 + 0.05); else: divide($l2 + 0.05, $l1 + 0.05))
More info: https://sass-lang.com/d/if-function
176 | @return if($l1 > $l2, divide($l1 + .05, $l2 + .05), divide($l2 + .05, $l1 + .05));
node_modules/bootstrap/scss/_functions.scss 177:11 @import
node_modules/bootstrap/scss/bootstrap.scss 7:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 184, column 9 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:184:9:
red() is deprecated. Suggestion:
color.channel($color, "red", $space: rgb)
More info: https://sass-lang.com/d/color-functions
184 | "r": red($color),
node_modules/bootstrap/scss/_functions.scss 185:10 luminance()
node_modules/bootstrap/scss/_functions.scss 174:8 contrast-ratio()
node_modules/bootstrap/scss/_functions.scss 159:22 color-contrast()
node_modules/bootstrap/scss/_variables.scss 846:42 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 185, column 9 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:185:9:
green() is deprecated. Suggestion:
color.channel($color, "green", $space: rgb)
More info: https://sass-lang.com/d/color-functions
185 | "g": green($color),
node_modules/bootstrap/scss/_functions.scss 186:10 luminance()
node_modules/bootstrap/scss/_functions.scss 174:8 contrast-ratio()
node_modules/bootstrap/scss/_functions.scss 159:22 color-contrast()
node_modules/bootstrap/scss/_variables.scss 846:42 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 186, column 9 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:186:9:
blue() is deprecated. Suggestion:
color.channel($color, "blue", $space: rgb)
More info: https://sass-lang.com/d/color-functions
186 | "b": blue($color)
node_modules/bootstrap/scss/_functions.scss 187:10 luminance()
node_modules/bootstrap/scss/_functions.scss 174:8 contrast-ratio()
node_modules/bootstrap/scss/_functions.scss 159:22 color-contrast()
node_modules/bootstrap/scss/_variables.scss 846:42 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 190, column 12 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:190:12:
The Sass if() syntax is deprecated in favor of the modern CSS syntax.
Suggestion: if(sass(divide($value, 255) < 0.04045): divide(divide($value, 255), 12.92); else: nth($_luminance-list, $value + 1))
More info: https://sass-lang.com/d/if-function
190 | $value: if(divide($value, 255) < .04045, divide(divide($value, 255), 12.92), nth($_luminance-list, $value + 1));
node_modules/bootstrap/scss/_functions.scss 191:13 @import
node_modules/bootstrap/scss/bootstrap.scss 7:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 206, column 10 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:206:10:
Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
Use color.mix instead.
More info and automated migrator: https://sass-lang.com/d/import
206 | @return mix(white, $color, $weight);
node_modules/bootstrap/scss/_functions.scss 207:11 tint-color()
node_modules/bootstrap/scss/_variables.scss 79:12 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 211, column 10 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:211:10:
Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
Use color.mix instead.
More info and automated migrator: https://sass-lang.com/d/import
211 | @return mix(black, $color, $weight);
node_modules/bootstrap/scss/_functions.scss 212:11 shade-color()
node_modules/bootstrap/scss/_variables.scss 84:12 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 216, column 10 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:216:10:
The Sass if() syntax is deprecated in favor of the modern CSS syntax.
Suggestion: if(sass($weight > 0): shade-color($color, $weight); else: tint-color($color, -$weight))
More info: https://sass-lang.com/d/if-function
216 | @return if($weight > 0, shade-color($color, $weight), tint-color($color, -$weight));
node_modules/bootstrap/scss/_functions.scss 217:11 @import
node_modules/bootstrap/scss/bootstrap.scss 7:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 341, column 26 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_variables.scss:341:26:
Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
Use color.mix instead.
More info and automated migrator: https://sass-lang.com/d/import
341 | $light-bg-subtle: mix($gray-100, $white) !default;
node_modules/bootstrap/scss/_variables.scss 342:27 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 36, column 10 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:36:10:
red() is deprecated. Suggestion:
color.channel($color, "red", $space: rgb)
More info: https://sass-lang.com/d/color-functions
36 | @return red($value), green($value), blue($value);
node_modules/bootstrap/scss/_functions.scss 37:11 to-rgb()
node_modules/bootstrap/scss/_variables.scss 846:31 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 36, column 23 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:36:23:
green() is deprecated. Suggestion:
color.channel($color, "green", $space: rgb)
More info: https://sass-lang.com/d/color-functions
36 | @return red($value), green($value), blue($value);
node_modules/bootstrap/scss/_functions.scss 37:24 to-rgb()
node_modules/bootstrap/scss/_variables.scss 846:31 @import
node_modules/bootstrap/scss/bootstrap.scss 8:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 57, column 29 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:57:29:
The Sass if() syntax is deprecated in favor of the modern CSS syntax.
Suggestion: if(sass($arg == "$key"): $key; else: if($arg == "$value", $value, $arg))
More info: https://sass-lang.com/d/if-function
57 | $_args: append($_args, if($arg == "$key", $key, if($arg == "$value", $value, $arg)));
node_modules/bootstrap/scss/_functions.scss 58:30 @import
node_modules/bootstrap/scss/bootstrap.scss 7:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 57, column 54 of file:///Volumes/McFiver/u/GIT/next-explorer/node_modules/bootstrap/scss/_functions.scss:57:54:
The Sass if() syntax is deprecated in favor of the modern CSS syntax.
Suggestion: if(sass($arg == "$value"): $value; else: $arg)
More info: https://sass-lang.com/d/if-function
57 | $_args: append($_args, if($arg == "$key", $key, if($arg == "$value", $value, $arg)));
node_modules/bootstrap/scss/_functions.scss 58:55 @import
node_modules/bootstrap/scss/bootstrap.scss 7:9 @import
src/scss/nbn.scss 9:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 6, column 8 of file:///Volumes/McFiver/u/GIT/next-explorer/src/scss/nbn.scss:6:8:
Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
More info and automated migrator: https://sass-lang.com/d/import
6 | @import "variables";
src/scss/nbn.scss 7:9 root stylesheet
./src/scss/nbn.scss
Issue while running loader
SassWarning: Deprecation Warning on line 8, column 8 of file:///Volumes/McFiver/u/GIT/next-explorer/src/scss/nbn.scss:8:8:
Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
More info and automated migrator: https://sass-lang.com/d/import
8 | @import "../../node_modules/bootstrap/scss/bootstrap";
src/scss/nbn.scss 9:9 root stylesheet
✓ Compiled successfully in 3.7s
./src/app/zxdb/releases/ReleasesExplorer.tsx
142:6 Warning: React Hook useEffect has missing dependencies: 'fetchData', 'initial', 'initialUrlState?.casetypeId', 'initialUrlState?.dLanguageId', 'initialUrlState?.dMachinetypeId', 'initialUrlState?.filetypeId', 'initialUrlState?.isDemo', 'initialUrlState?.q', 'initialUrlState?.schemetypeId', 'initialUrlState?.sort', 'initialUrlState?.sourcetypeId', 'initialUrlState?.year', 'q', and 'updateUrl'. Either include them or remove the dependency array. react-hooks/exhaustive-deps
info - Need to disable some ESLint rules? Learn more here: https://nextjs.org/docs/app/api-reference/config/eslint#disabling-rules
Failed to compile.
./src/server/repo/zxdb.ts:491:17
Type error: Argument of type 'Name' is not assignable to parameter of type 'SQL<unknown> | Column<ColumnBaseConfig<ColumnDataType, string>, object, object> | Aliased<unknown>'.
Type 'Name' is missing the following properties from type 'Aliased<unknown>': sql, fieldAlias, _
489 | .select({ total: sql<number>`count(distinct ${sql.identifier("label_id")})` })
490 | .from(sql`search_by_names`)
> 491 | .where(like(sql.identifier("label_name"), pattern));
| ^
492 | const total = Number(countRows[0]?.total ?? 0);
493 |
494 | const items = await db
Next.js build worker exited with code: 1 and signal: null

157
AGENTS.md Normal file
View File

@@ -0,0 +1,157 @@
# AGENT.md
This document provides an overview of the Next Explorer project, its structure, and its implementation details.
## Project Overview
Next Explorer is a web application for exploring the Spectrum Next ecosystem. It is built with Next.js (App Router), React, and TypeScript.
It has two main areas:
- Registers: parsed from `data/nextreg.txt`, browsable with real-time filtering and deep links.
- ZXDB Explorer: a deep, crosslinked browser for ZXDB entries, labels, genres, languages, and machine types backed by MySQL using Drizzle ORM.
## Project Structure
The project is a Next.js application with the following structure:
```
next-explorer/
├── eslint.config.mjs
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── data/
│ ├── nextreg.txt
│ ├── custom_parsers.txt
│ └── wikilinks.txt
├── node_modules/...
├── public/...
└── src/
├── middleware.js
├── app/
│ ├── layout.tsx
│ ├── page.module.css
│ ├── page.tsx
│ └── registers/
│ ├── page.tsx
│ ├── RegisterBrowser.tsx
│ ├── RegisterDetail.tsx
│ └── [hex]/
│ └── page.tsx
├── components/
│ ├── Navbar.tsx
│ └── ThemeDropdown.tsx
├── scss/
│ ├── _bootswatch.scss
│ ├── _explorer.scss
│ ├── _variables.scss
│ └── nbn.scss
├── services/
│ └── register.service.ts
└── utils/
├── register_parser.ts
└── register_parsers/
├── reg_default.ts
└── reg_f0.ts
```
- **`data/`**: Contains the raw input data for the Spectrum Next explorer.
- `nextreg.txt`: Main register definition file.
- `custom_parsers.txt`, `wikilinks.txt`: Auxiliary configuration/data used by the parser.
- **`src/app/`**: Next.js App Router entrypoint.
- `layout.tsx`: Root layout for all routes.
- `page.tsx`: Application home page.
- `registers/`: Routes and components for the register explorer.
- `page.tsx`: Server Component that loads and lists all registers.
- `RegisterBrowser.tsx`: Client Component implementing search/filter and listing.
- `RegisterDetail.tsx`: Client Component that renders a single registers details, including modes, notes, and source modal.
- `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address.
- `src/app/zxdb/`: ZXDB Explorer routes and client components.
- `page.tsx` + `ZxdbExplorer.tsx`: Search + filters with server-rendered initial content and ISR.
- `entries/[id]/page.tsx` + `EntryDetail.tsx`: Entry details (SSR initial data).
- `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail.
- `genres/`, `languages/`, `machinetypes/`: Category hubs and detail pages.
- `src/app/api/zxdb/`: Zodvalidated API routes (Node runtime) for search and category browsing.
- `src/server/`:
- `env.ts`: Zod env parsing/validation (t3.gg style). Validates `ZXDB_URL` (mysql://).
- `server/db.ts`: Drizzle over `mysql2` singleton pool.
- `server/schema/zxdb.ts`: Minimal Drizzle models (entries, labels, helper tables, lookups).
- `server/repo/zxdb.ts`: Repository queries for search, details, categories, and facets.
- **`src/components/`**: Shared UI components such as `Navbar` and `ThemeDropdown`.
- **`src/services/register.service.ts`**: Service layer responsible for loading and caching parsed register data.
- **`src/utils/register_parser.ts` & `src/utils/register_parsers/`**: Parsing logic for `nextreg.txt`, including mode/bitfield handling and any register-specific parsing extensions.
## Implementation Details
Comment what the code does, not what the agent has done. The documentation's purpose is the state of the application today, not a log of actions taken.
### Data Parsing
- `getRegisters()` in `src/services/register.service.ts`:
- Reads `data/nextreg.txt` from disk.
- Uses `parseNextReg()` from `src/utils/register_parser.ts` to convert the raw text into an array of `Register` objects.
- Returns the in-memory representation of all registers (and can be extended to cache results across calls).
- `parseNextReg()` and related helpers in `register_parser.ts`:
- Parse the custom `nextreg.txt` format into structured data:
- Register addresses (hex/dec).
- Names, notes, and descriptive text.
- Per-mode read/write/common bitfield views.
- Optional source lines and external links (e.g. wiki URLs).
- Delegate special-case parsing to functions in `src/utils/register_parsers/` (e.g. `reg_default.ts`, `reg_f0.ts`) when needed.
### TypeScript Patterns
- No explicity any types.
- Use `const` for constants.
- Use `type` for interfaces.
- No `enum`.
### React / Next.js Patterns
- **Server Components**:
- `src/app/registers/page.tsx` and `src/app/registers/[hex]/page.tsx` are Server Components.
- ZXDB pages under `/zxdb` serverrender initial content for fast first paint, with ISR (`export const revalidate = 3600`) on nonsearch pages.
- Server components call repository functions directly on the server and pass data to client components for presentation.
- **Client Components**:
- `RegisterBrowser.tsx`:
- Marked with `'use client'`.
- Uses React state to manage search input and filtered results.
- `RegisterDetail.tsx`:
- Marked with `'use client'`.
- Renders a single register with tabs for different access modes.
- ZXDB client components (e.g., `ZxdbExplorer.tsx`, `EntryDetail.tsx`, `labels/*`) receive initial data from the server and keep interactions on the client without blocking the first paint.
- **Dynamic Routing**:
- Pages and API routes must await dynamic params in Next.js 15:
- Pages: `export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; }`
- API: `export async function GET(req, ctx: { params: Promise<{ id: string }> }) { const raw = await ctx.params; /* validate with Zod */ }`
- `src/app/registers/[hex]/page.tsx` resolves the `[hex]` segment and calls `notFound()` if absent.
### ZXDB Integration
- Database connection via `mysql2` pool wrapped by Drizzle (`src/server/db.ts`).
- Env validation via Zod (`src/env.ts`) ensures `ZXDB_URL` is a valid `mysql://` URL.
- Minimal Drizzle schema models used for fast search and lookups (`src/server/schema/zxdb.ts`).
- Repository consolidates SQL with typed results (`src/server/repo/zxdb.ts`).
- API routes under `/api/zxdb/*` validate inputs with Zod and run on Node runtime.
- UI under `/zxdb` is deeply crosslinked and serverrenders initial data for performance. Links use Next `Link` to enable prefetching.
- Helper SQL `ZXDB/scripts/ZXDB_help_search.sql` must be run to create `search_by_*` tables for efficient searches.
- Lookup tables use column `text` for display names; the Drizzle schema maps it as `name`.
### Working Patterns***
- git branching:
- Do not create new branches
- git commits:
- Create COMMIT_EDITMSG file, await any user edits, then commit using that
commit note, and then delete the COMMIT_EDITMSG file. Remember to keep
the first line as the subject <50char
- git commit messages:
- Use imperative mood (e.g., "Add feature X", "Fix bug Y").
- Include relevant issue numbers if applicable.
- Sign-off commit message as Junie@<hostname>
### References
- ZXDB setup and API usage: `docs/ZXDB.md`

1
COMMIT_EDITMSG Normal file
View File

@@ -0,0 +1 @@
Link entry_id across UI; surface aliases/webrefs on Entry\n\n- Add EntryLink component for /zxdb/entries/[id]\n- Use EntryLink in Entries, Releases, and Label detail tables\n- Extend Entry detail with Aliases and Web links sections\n- Add Drizzle schema for aliases, webrefs, websites; fetch in repo\n\nSigned-off-by: Junie@lucy\n

View File

@@ -1,14 +1,13 @@
Spectrum Next Explorer Spectrum Next Explorer
A Next.js application for exploring the Spectrum Next hardware. It includes a Register Explorer with realtime search and deeplinkable queries. A Next.js application for exploring the Spectrum Next ecosystem. It ships with:
Features - Register Explorer: parsed from `data/nextreg.txt`, with realtime search and deep links
- Register Explorer parsed from `data/nextreg.txt` - ZXDB Explorer: a deep, crosslinked browser for entries, labels, genres, languages, and machine types backed by a MySQL ZXDB instance
- Realtime filtering with querystring deep links (e.g. `/registers?q=vram`)
- Bootstrap 5 theme with light/dark support - Bootstrap 5 theme with light/dark support
Quick start Quick start
- Prerequisites: Node.js 20+, pnpm (recommended) - Prerequisites: Node.js 20+, pnpm (recommended), access to a MySQL server for ZXDB (optional for Registers)
- Install dependencies: - Install dependencies:
- `pnpm install` - `pnpm install`
- Run in development (Turbopack, port 4000): - Run in development (Turbopack, port 4000):
@@ -26,11 +25,53 @@ Project scripts (package.json)
- `deploy-test`: push to `test.explorer.specnext.dev` - `deploy-test`: push to `test.explorer.specnext.dev`
- `deploy-prod`: push to `explorer.specnext.dev` - `deploy-prod`: push to `explorer.specnext.dev`
Documentation Routes
- Docs index: `docs/index.md` - `/` — Home
- Getting Started: `docs/getting-started.md` - `/registers` — Register Explorer
- Architecture: `docs/architecture.md` - `/zxdb` — ZXDB Explorer (search + filters)
- Register Explorer: `docs/registers.md` - `/zxdb/entries/[id]` — Entry detail
- `/zxdb/labels` and `/zxdb/labels/[id]` — Labels search and detail
- `/zxdb/genres` and `/zxdb/genres/[id]` — Genres list and entries
- `/zxdb/languages` and `/zxdb/languages/[id]` — Languages list and entries
- `/zxdb/machinetypes` and `/zxdb/machinetypes/[id]` — Machine types list and entries
ZXDB setup (database, env, and helper tables)
The Registers section works without any database. The ZXDB Explorer requires a MySQL ZXDB database and one environment variable.
1) Prepare the database (outside this app)
- Import ZXDB data into MySQL. If you want only structure, use `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` in this repo. For data, import ZXDB via your normal process.
- Create the helper search tables (required for fast search):
- Run `ZXDB/scripts/ZXDB_help_search.sql` against your ZXDB database.
- Create a readonly role/user (recommended):
- Example (see `bin/import_mysql.sh`):
- Create role `zxdb_readonly`
- Grant `SELECT, SHOW VIEW` on database `zxdb`
2) Configure environment
- Copy `.env` from `example.env`.
- Set `ZXDB_URL` to a MySQL URL, e.g. `mysql://zxdb_readonly:password@hostname:3306/zxdb`.
- On startup, `src/env.ts` validates env vars (t3.gg pattern with Zod) and will fail fast if invalid.
3) Run the app
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`.
API (selected endpoints)
- `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1`
- `GET /api/zxdb/entries/[id]`
- `GET /api/zxdb/labels/search?q=...`
- `GET /api/zxdb/labels/[id]?page=1&pageSize=20`
- `GET /api/zxdb/genres` and `/api/zxdb/genres/[id]?page=1`
- `GET /api/zxdb/languages` and `/api/zxdb/languages/[id]?page=1`
- `GET /api/zxdb/machinetypes` and `/api/zxdb/machinetypes/[id]?page=1`
Implementation notes
- Next.js 15 dynamic params: pages and API routes that consume `params` must await it, e.g. `export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; }`
- ZXDB integration uses Drizzle ORM over `mysql2` with a singleton pool at `src/server/db.ts`; API routes declare `export const runtime = "nodejs"`.
- Entry and detail pages serverrender initial content and use ISR (`revalidate = 3600`) for fast timetocontent; index pages avoid a blocking first client fetch.
Further reading
- ZXDB details and API usage: `docs/ZXDB.md`
- Agent/developer workflow and commit guidelines: `AGENTS.md`
License License
- See `LICENSE.txt` for details. - See `LICENSE.txt` for details.

1
ZXDB Submodule

Submodule ZXDB added at 3784c91bdd

12
bin/import_mysql.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
mysql -uroot -p -hquinn < ZXDB/ZXDB_mysql.sql
{ 1 ↵ git:feat/zxdb ✗› v22.21.1
echo "SET @OLD_SQL_MODE := @@SESSION.sql_mode;"
echo "SET SESSION sql_mode := REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '');"
cat ZXDB/scripts/ZXDB_help_search.sql
echo "SET SESSION sql_mode := @OLD_SQL_MODE;"
echo "CREATE ROLE 'zxdb_readonly';"
echo "GRANT SELECT, SHOW VIEW ON `zxdb`.* TO 'zxdb_readonly';"
} | mysql -uroot -p -hquinn zxdb
mysqldump --no-data -hquinn -uroot -p zxdb > ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql

110
docs/ZXDB.md Normal file
View File

@@ -0,0 +1,110 @@
# ZXDB Guide
This document explains how the ZXDB Explorer works in this project, how to set up the database connection, and how to use the builtin API and UI for software discovery.
## What is ZXDB?
ZXDB ( https://github.com/zxdb/ZXDB )is a communitymaintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in readonly mode and expose a fast, crosslinked explorer UI under `/zxdb`.
## Prerequisites
- MySQL server with ZXDB data (or at minimum the tables; data is needed to browse).
- Ability to run the helper SQL that builds search tables (required for efficient LIKE searches).
- A readonly MySQL user for the app (recommended).
## Database setup
1. Import ZXDB data into MySQL.
- Extract and import https://github.com/zxdb/ZXDB/blob/master/ZXDB_mysql.sql.zip
2. Create helper search tables (required).
- Run `https://github.com/zxdb/ZXDB/blob/master/scripts/ZXDB_help_search.sql` on your ZXDB database.
- This creates `search_by_titles`, `search_by_names`, `search_by_authors`, and `search_by_publishers` tables.
3. Create a readonly role/user (recommended).
- Create user `zxdb_readonly`.
- Grant `SELECT, SHOW VIEW` on your `zxdb` database to the user.
## Environment configuration
Set the connection string in `.env`:
```
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
```
Notes:
- The URL must start with `mysql://`. Env is validated at boot by `src/env.ts` (Zod), failing fast if misconfigured.
- The app uses a singleton `mysql2` pool (`src/server/db.ts`) and Drizzle ORM for typed queries.
## Running
```
pnpm install
pnpm dev
# open http://localhost:4000 and navigate to /zxdb
```
## Explorer UI overview
- `/zxdb` — Search entries by title and filter by genre, language, and machine type; sort and paginate results.
- `/zxdb/entries/[id]` — Entry details with badges for genre/language/machine, and linked authors/publishers.
- `/zxdb/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies) and view authored/published entries.
- `/zxdb/genres`, `/zxdb/languages`, `/zxdb/machinetypes` — Category hubs with linked detail pages listing entries.
Crosslinking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched.
Performance: Detail and index pages are serverrendered with initial data and use ISR (`revalidate = 3600`) to reduce timetofirstcontent. Queries select only required columns and leverage helper tables for text search.
## HTTP API reference (selected)
All endpoints are under `/api/zxdb` and validate inputs with Zod. Responses are JSON.
- Search entries
- `GET /api/zxdb/search`
- Query params:
- `q` — string (freetext search; normalized via helper tables)
- `page`, `pageSize` — pagination (default pageSize=20, max=100)
- `genreId`, `languageId`, `machinetypeId` — optional filters
- `sort``title` or `id_desc`
- `facets` — boolean; if truthy, includes facet counts for genres/languages/machines
- Entry detail
- `GET /api/zxdb/entries/[id]`
- Returns: entry core fields, joined genre/language/machinetype names, authors and publishers.
- Labels
- `GET /api/zxdb/labels/search?q=...`
- `GET /api/zxdb/labels/[id]?page=1&pageSize=20` — includes `authored` and `published` lists.
- Categories
- `GET /api/zxdb/genres` and `/api/zxdb/genres/[id]?page=1`
- `GET /api/zxdb/languages` and `/api/zxdb/languages/[id]?page=1`
- `GET /api/zxdb/machinetypes` and `/api/zxdb/machinetypes/[id]?page=1`
Runtime: API routes declare `export const runtime = "nodejs"` to support `mysql2`.
## Implementation notes
- Drizzle models map ZXDB lookup table column `text` to property `name` for ergonomics (e.g., `languages.text``name`).
- Next.js 15 dynamic params must be awaited in App Router pages and API routes. Example:
```ts
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// ...
}
```
- Repository queries parallelize independent calls with `Promise.all` for lower latency.
## Troubleshooting
- 400 from dynamic API routes: ensure you await `ctx.params` before Zod validation.
- Unknown column errors for lookup names: ZXDB tables use column `text` for names; Drizzle schema must select `text` as `name`.
- Slow entry page: confirm serverrendering is active and ISR is set; client components should not fetch on the first paint when initial props are provided.
- MySQL auth or network errors: verify `ZXDB_URL` and that your user has read permissions.
## Roadmap
- Facet counts displayed in the `/zxdb` filter UI.
- Breadcrumbs and additional a11y polish.
- Media assets and download links per release (future).

14
drizzle.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Config } from "drizzle-kit";
// This configuration is optional at the moment (no migrations run here),
// but kept for future schema generation if needed.
export default {
schema: "./src/server/schema/**/*.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
// Read from env at runtime when using drizzle-kit
url: process.env.ZXDB_URL!,
},
} satisfies Config;

7
example.env Normal file
View File

@@ -0,0 +1,7 @@
# ZXDB MySQL connection URL
# Example using a readonly user created by ZXDB scripts
# CREATE ROLE 'zxdb_readonly';
# GRANT SELECT, SHOW VIEW ON `zxdb`.* TO 'zxdb_readonly';
# See docs/ZXDB.md for full setup instructions (DB import, helper tables,
# readonly role, and environment validation notes).
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb

View File

@@ -1,7 +1,6 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-explorer", "name": "next-explorer",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "PORT=4000 next dev --turbopack", "dev": "PORT=4000 next dev --turbopack",
@@ -12,20 +12,24 @@
}, },
"dependencies": { "dependencies": {
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"next": "~15.5.7", "drizzle-orm": "^0.36.4",
"mysql2": "^3.16.0",
"next": "~15.5.9",
"react": "19.1.0", "react": "19.1.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-bootstrap-icons": "^1.11.6", "react-bootstrap-icons": "^1.11.6",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@types/node": "^20.19.25", "@types/node": "^20.19.27",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.1", "eslint": "^9.39.2",
"eslint-config-next": "15.5.4", "eslint-config-next": "15.5.4",
"sass": "^1.94.2" "drizzle-kit": "^0.30.6",
"sass": "^1.97.0"
} }
} }

1076
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View 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";

View 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";

View 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";

View File

@@ -0,0 +1,33 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { getEntryById } from "@/server/repo/zxdb";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const parsed = paramsSchema.safeParse(raw);
if (!parsed.success) {
return new Response(JSON.stringify({ error: parsed.error.flatten() }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const id = parsed.data.id;
const detail = await getEntryById(id);
if (!detail) {
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(detail), {
headers: {
"content-type": "application/json",
// Cache for 1h on CDN, allow stale while revalidating for a day
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const runtime = "nodejs";

View 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";

View File

@@ -0,0 +1,31 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { entriesByGenre } from "@/server/repo/zxdb";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
const querySchema = z.object({
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().max(100).optional(),
});
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
}
const { searchParams } = new URL(req.url);
const q = querySchema.safeParse({
page: searchParams.get("page") ?? undefined,
pageSize: searchParams.get("pageSize") ?? undefined,
});
if (!q.success) {
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
}
const page = q.data.page ?? 1;
const pageSize = q.data.pageSize ?? 20;
const data = await entriesByGenre(p.data.id, page, pageSize);
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,13 @@
import { listGenres } from "@/server/repo/zxdb";
export async function GET() {
const data = await listGenres();
return new Response(JSON.stringify({ items: data }), {
headers: {
"content-type": "application/json",
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,51 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
const querySchema = z.object({
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().max(100).optional(),
});
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const { searchParams } = new URL(req.url);
const q = querySchema.safeParse({
page: searchParams.get("page") ?? undefined,
pageSize: searchParams.get("pageSize") ?? undefined,
});
if (!q.success) {
return new Response(JSON.stringify({ error: q.error.flatten() }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const id = p.data.id;
const label = await getLabelById(id);
if (!label) {
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "content-type": "application/json" },
});
}
const page = q.data.page ?? 1;
const pageSize = q.data.pageSize ?? 20;
const [authored, published] = await Promise.all([
getLabelAuthoredEntries(id, { page, pageSize }),
getLabelPublishedEntries(id, { page, pageSize }),
]);
return new Response(
JSON.stringify({ label, authored, published }),
{ headers: { "content-type": "application/json", "cache-control": "no-store" } }
);
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,30 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { searchLabels } 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(),
});
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,
});
if (!parsed.success) {
return new Response(
JSON.stringify({ error: parsed.error.flatten() }),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const data = await searchLabels(parsed.data);
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,31 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { entriesByLanguage } from "@/server/repo/zxdb";
const paramsSchema = z.object({ id: z.string().trim().length(2) });
const querySchema = z.object({
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().max(100).optional(),
});
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
}
const { searchParams } = new URL(req.url);
const q = querySchema.safeParse({
page: searchParams.get("page") ?? undefined,
pageSize: searchParams.get("pageSize") ?? undefined,
});
if (!q.success) {
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
}
const page = q.data.page ?? 1;
const pageSize = q.data.pageSize ?? 20;
const data = await entriesByLanguage(p.data.id, page, pageSize);
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,13 @@
import { listLanguages } from "@/server/repo/zxdb";
export async function GET() {
const data = await listLanguages();
return new Response(JSON.stringify({ items: data }), {
headers: {
"content-type": "application/json",
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,31 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { entriesByMachinetype } from "@/server/repo/zxdb";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
const querySchema = z.object({
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().max(100).optional(),
});
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
}
const { searchParams } = new URL(req.url);
const q = querySchema.safeParse({
page: searchParams.get("page") ?? undefined,
pageSize: searchParams.get("pageSize") ?? undefined,
});
if (!q.success) {
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
}
const page = q.data.page ?? 1;
const pageSize = q.data.pageSize ?? 20;
const data = await entriesByMachinetype(p.data.id, page, pageSize);
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,13 @@
import { listMachinetypes } from "@/server/repo/zxdb";
export async function GET() {
const data = await listMachinetypes();
return new Response(JSON.stringify({ items: data }), {
headers: {
"content-type": "application/json",
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const runtime = "nodejs";

View 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";

View 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";

View 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";

View File

@@ -0,0 +1,48 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { searchEntries, getEntryFacets } 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(),
genreId: z.coerce.number().int().positive().optional(),
languageId: z
.string()
.trim()
.length(2, "languageId must be a 2-char code")
.optional(),
machinetypeId: z.coerce.number().int().positive().optional(),
sort: z.enum(["title", "id_desc"]).optional(),
facets: 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,
genreId: searchParams.get("genreId") ?? undefined,
languageId: searchParams.get("languageId") ?? undefined,
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
sort: searchParams.get("sort") ?? undefined,
facets: searchParams.get("facets") ?? undefined,
});
if (!parsed.success) {
return new Response(
JSON.stringify({ error: parsed.error.flatten() }),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const data = await searchEntries(parsed.data);
const body = parsed.data.facets
? { ...data, facets: await getEntryFacets(parsed.data) }
: data;
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});
}
// Ensure Node.js runtime (required for mysql2)
export const runtime = "nodejs";

View 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";

View File

@@ -5,7 +5,7 @@ import NavbarClient from "@/components/Navbar";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Spectrum Next Explorer", title: "Spectrum Next Explorer",
description: "A platform for exploring the Spectrum Next hardware", description: "A platform for exploring the Spectrum Next ecosystem",
robots: { index: true, follow: true }, robots: { index: true, follow: true },
formatDetection: { email: false, address: false, telephone: false }, formatDetection: { email: false, address: false, telephone: false },
}; };

View File

@@ -1,13 +1,13 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Register } from '@/utils/register_parser';
import RegisterDetail from '@/app/registers/RegisterDetail'; import RegisterDetail from '@/app/registers/RegisterDetail';
import {Container, Row} from "react-bootstrap"; import {Container, Row} from "react-bootstrap";
import { getRegisters } from '@/services/register.service'; import { getRegisters } from '@/services/register.service';
export default async function RegisterDetailPage({ params }: { params: { hex: string } }) { export default async function RegisterDetailPage({ params }: { params: Promise<{ hex: string }> }) {
const registers = await getRegisters(); const registers = await getRegisters();
const targetHex = decodeURIComponent((await params).hex).toLowerCase(); const { hex } = await params;
const targetHex = decodeURIComponent(hex).toLowerCase();
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex); const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);

View File

@@ -0,0 +1,258 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
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 ZxdbExplorer({
initial,
initialGenres,
initialLanguages,
initialMachines,
}: {
initial?: Paged<Item>;
initialGenres?: { id: number; name: string }[];
initialLanguages?: { id: string; name: string }[];
initialMachines?: { id: number; name: string }[];
}) {
const [q, setQ] = useState("");
const [page, setPage] = useState(initial?.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 | "">("");
const [languageId, setLanguageId] = useState<string | "">("");
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
const [sort, setSort] = useState<"title" | "id_desc">("id_desc");
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
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) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}
useEffect(() => {
// When navigating via Next.js Links that change ?page=, SSR provides new `initial`.
// Sync local state from new SSR payload so the list and counter update immediately
// without an extra client fetch.
if (initial) {
setData(initial);
setPage(initial.page);
}
}, [initial]);
useEffect(() => {
// Avoid immediate client fetch on first paint if server provided initial data for this exact state
const initialPage = initial?.page ?? 1;
if (
initial &&
page === initialPage &&
q === "" &&
genreId === "" &&
languageId === "" &&
machinetypeId === "" &&
sort === "id_desc"
) {
return;
}
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);
fetchData(q, 1);
}
return (
<div>
<h1 className="mb-3">ZXDB Explorer</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} onChange={(e) => setGenreId(e.target.value === "" ? "" : Number(e.target.value))}>
<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} onChange={(e) => setLanguageId(e.target.value)}>
<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} onChange={(e) => setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value))}>
<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 "title" | "id_desc")}>
<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={`/zxdb?page=${Math.max(1, (data?.page ?? 1) - 1)}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb?page=${Math.min(totalPages, (data?.page ?? 1) + 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>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import Link from "next/link";
type Props = {
id: number;
title?: string;
className?: string;
};
export default function EntryLink({ id, title, className }: Props) {
const text = typeof title === "string" && title.length > 0 ? title : `#${id}`;
return (
<Link href={`/zxdb/entries/${id}`} className={className}>
{text}
</Link>
);
}

View File

@@ -0,0 +1,323 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
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) {
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);
}
}, [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} 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} 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} 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 "title" | "id_desc"); 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><EntryLink id={it.id} /></td>
<td>
<EntryLink id={it.id} title={it.title} />
</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>
);
}

View File

@@ -0,0 +1,410 @@
"use client";
import Link from "next/link";
type Label = { id: number; name: string; labeltypeId: string | null };
export type EntryDetailData = {
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[];
// extra fields for richer details
maxPlayers?: number;
availabletypeId?: string | null;
withoutLoadScreen?: number;
withoutInlay?: number;
issueId?: number | null;
files?: {
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
type: { id: number; name: string };
}[];
// Flat downloads by entry_id
downloadsFlat?: {
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
isDemo: boolean;
type: { id: number; name: string };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
scheme: { id: string | null; name: string | null };
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
releaseSeq: number;
}[];
releases?: {
releaseSeq: number;
type: { id: string | null; name: string | null };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
year: number | null;
comments: string | null;
downloads: {
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
isDemo: boolean;
type: { id: number; name: string };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
scheme: { id: string | null; name: string | null };
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
}[];
}[];
// Additional relationships
aliases?: { releaseSeq: number; languageId: string; title: string }[];
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
};
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
if (!data) return <div className="alert alert-warning">Not found</div>;
return (
<div>
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">{data.title}</h1>
{data.genre.name && (
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
{data.genre.name}
</Link>
)}
{data.language.name && (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
{data.language.name}
</Link>
)}
{data.machinetype.name && (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
{data.machinetype.name}
</Link>
)}
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
</div>
<hr />
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 220 }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>ID</td>
<td>{data.id}</td>
</tr>
<tr>
<td>Title</td>
<td>{data.title}</td>
</tr>
<tr>
<td>Machine</td>
<td>
{data.machinetype.id != null ? (
data.machinetype.name ? (
<Link href={`/zxdb/machinetypes/${data.machinetype.id}`}>{data.machinetype.name}</Link>
) : (
<span>#{data.machinetype.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<td>Language</td>
<td>
{data.language.id ? (
data.language.name ? (
<Link href={`/zxdb/languages/${data.language.id}`}>{data.language.name}</Link>
) : (
<span>{data.language.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<td>Genre</td>
<td>
{data.genre.id ? (
data.genre.name ? (
<Link href={`/zxdb/genres/${data.genre.id}`}>{data.genre.name}</Link>
) : (
<span>#{data.genre.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
{typeof data.maxPlayers !== "undefined" && (
<tr>
<td>Max Players</td>
<td>{data.maxPlayers}</td>
</tr>
)}
{typeof data.availabletypeId !== "undefined" && (
<tr>
<td>Available Type</td>
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
</tr>
)}
{typeof data.withoutLoadScreen !== "undefined" && (
<tr>
<td>Without Load Screen</td>
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
</tr>
)}
{typeof data.withoutInlay !== "undefined" && (
<tr>
<td>Without Inlay</td>
<td>{data.withoutInlay ? "Yes" : "No"}</td>
</tr>
)}
{typeof data.issueId !== "undefined" && (
<tr>
<td>Issue</td>
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
</tr>
)}
</tbody>
</table>
</div>
<hr />
{/* Downloads (flat, by entry_id). Render only this flat section; do not render grouped downloads here. */}
<div>
<h5>Downloads</h5>
{(!data.downloadsFlat || data.downloadsFlat.length === 0) && <div className="text-secondary">No downloads</div>}
{data.downloadsFlat && data.downloadsFlat.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 260 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.downloadsFlat.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
return (
<tr key={d.id}>
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
<td>
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
) : (
<span>{d.link}</span>
)}
</td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code>{d.md5 ?? "-"}</code></td>
<td>
<div className="d-flex gap-1 flex-wrap">
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{d.language.name && (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
)}
{d.machinetype.name && (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
)}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
<span className="badge text-bg-light">rel #{d.releaseSeq}</span>
</div>
</td>
<td>{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</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}>
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
</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}>
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
)}
</div>
</div>
<hr />
{/* Aliases (alternative titles) */}
<div>
<h5>Aliases</h5>
{(!data.aliases || data.aliases.length === 0) && <div className="text-secondary">No aliases</div>}
{data.aliases && data.aliases.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 90 }}>Release #</th>
<th style={{ width: 120 }}>Language</th>
<th>Title</th>
</tr>
</thead>
<tbody>
{data.aliases.map((a, idx) => (
<tr key={`${a.releaseSeq}-${a.languageId}-${idx}`}>
<td>#{a.releaseSeq}</td>
<td>{a.languageId}</td>
<td>{a.title}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<hr />
{/* Web links (external references) */}
<div>
<h5>Web links</h5>
{(!data.webrefs || data.webrefs.length === 0) && <div className="text-secondary">No web links</div>}
{data.webrefs && data.webrefs.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 120 }}>Language</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{data.webrefs.map((w, idx) => (
<tr key={`${w.website.id}-${idx}`}>
<td>
{w.website.link ? (
<a href={w.website.link} target="_blank" rel="noopener noreferrer">{w.website.name}</a>
) : (
<span>{w.website.name}</span>
)}
</td>
<td>{w.languageId}</td>
<td>
<a href={w.link} target="_blank" rel="noopener noreferrer">{w.link}</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>Files</h5>
{(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>}
{data.files && data.files.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 260 }}>MD5</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.files.map((f) => {
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
return (
<tr key={f.id}>
<td><span className="badge text-bg-secondary">{f.type.name}</span></td>
<td>
{isHttp ? (
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
) : (
<span>{f.link}</span>
)}
</td>
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
<td><code>{f.md5 ?? "-"}</code></td>
<td>{f.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<hr />
{/* Removed grouped releases/downloads section to avoid duplicate downloads UI. */}
<div className="d-flex align-items-center gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import EntryDetailClient from "./EntryDetail";
import { getEntryById } from "@/server/repo/zxdb";
export const metadata = {
title: "ZXDB Entry",
};
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const numericId = Number(id);
const data = await getEntryById(numericId);
// For simplicity, let the client render a Not Found state if null
return <EntryDetailClient data={data} />;
}

View 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) ?? "id_desc") as "title" | "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}
initialGenres={genres}
initialLanguages={langs}
initialMachines={machines}
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }}
/>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import Link from "next/link";
type Genre = { id: number; name: string };
export default function GenreList({ items }: { items: Genre[] }) {
return (
<div>
<h1>Genres</h1>
<ul className="list-group">
{items.map((g) => (
<li key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
<span className="badge text-bg-light">#{g.id}</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Genre = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Genre>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Genre> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/genres?${params.toString()}`);
}
return (
<div>
<h1>Genres</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((g) => (
<tr key={g.id}>
<td><span className="badge text-bg-light">#{g.id}</span></td>
<td>
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
</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={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { 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 GenreDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
return (
<div>
<h1 className="mb-0">Genre <span className="badge text-bg-light">#{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this genre…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{initial && initial.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>
{initial.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 className="d-flex align-items-center gap-2 mt-2">
<span>Page {initial.page} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
aria-disabled={initial.page <= 1}
href={`/zxdb/genres/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
aria-disabled={initial.page >= totalPages}
href={`/zxdb/genres/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import GenreDetailClient from "./GenreDetail";
import { entriesByGenre } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Genre" };
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id);
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 initial = await entriesByGenre(numericId, page, 20, q || undefined);
return <GenreDetailClient id={numericId} initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,14 @@
import GenresSearch from "./GenresSearch";
import { searchGenres } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Genres" };
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchGenres({ q, page, pageSize: 20 });
return <GenresSearch initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<Label>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Label> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/labels?${params.toString()}`);
}
return (
<div>
<h1>Labels</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 100 }}>ID</th>
<th>Name</th>
<th style={{ width: 120 }}>Type</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td>#{l.id}</td>
<td>
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
</td>
<td>
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</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={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import EntryLink from "../../components/EntryLink";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
type Label = { id: number; name: string; labeltypeId: string | null };
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 };
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
export default function LabelDetailClient({ id, initial, initialTab, initialQ }: { id: number; initial: Payload; initialTab?: "authored" | "published"; initialQ?: string }) {
// Keep only interactive UI state (tab). Data should come directly from SSR props so it updates on navigation.
const [tab, setTab] = useState<"authored" | "published">(initialTab ?? "authored");
const [q, setQ] = useState(initialQ ?? "");
const router = useRouter();
// Names are now delivered by SSR payload to minimize pop-in.
// Hooks must be called unconditionally
const current = useMemo<Paged<Item> | null>(
() => (tab === "authored" ? initial?.authored : initial?.published) ?? null,
[initial, tab]
);
const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]);
if (!initial || !initial.label) return <div className="alert alert-warning">Not found</div>;
return (
<div>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 className="mb-0">{initial.label.name}</h1>
<div>
<span className="badge text-bg-light">{initial.label.labeltypeId ?? "?"}</span>
</div>
</div>
<ul className="nav nav-tabs mt-3">
<li className="nav-item">
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
</li>
<li className="nav-item">
<button className={`nav-link ${tab === "published" ? "active" : ""}`} onClick={() => setTab("published")}>Published</button>
</li>
</ul>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder={`Search within ${tab}`} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.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>
{current.items.map((it) => (
<tr key={it.id}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></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 {current ? current.page : 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-sm btn-outline-secondary ${current && current.page <= 1 ? "disabled" : ""}`}
aria-disabled={current ? current.page <= 1 : true}
href={`/zxdb/labels/${id}?${(() => { const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", String(Math.max(1, (current ? current.page : 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${current && current.page >= totalPages ? "disabled" : ""}`}
aria-disabled={current ? current.page >= totalPages : true}
href={`/zxdb/labels/${id}?${(() => { const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (current ? current.page : 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import LabelDetailClient from "./LabelDetail";
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Label" };
// Depends on searchParams (?page=, ?tab=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const tab = (Array.isArray(sp.tab) ? sp.tab[0] : sp.tab) as "authored" | "published" | undefined;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const [label, authored, published] = await Promise.all([
getLabelById(numericId),
getLabelAuthoredEntries(numericId, { page, pageSize: 20, q: q || undefined }),
getLabelPublishedEntries(numericId, { page, pageSize: 20, q: q || undefined }),
]);
// Let the client component handle the "not found" simple state
return <LabelDetailClient id={numericId} initial={{ label: label, authored: authored, published: published }} initialTab={tab} initialQ={q} />;
}

View File

@@ -0,0 +1,15 @@
import LabelsSearch from "./LabelsSearch";
import { searchLabels } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Labels" };
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchLabels({ q, page, pageSize: 20 });
return <LabelsSearch initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,21 @@
"use client";
import Link from "next/link";
type Language = { id: string; name: string };
export default function LanguageList({ items }: { items: Language[] }) {
return (
<div>
<h1>Languages</h1>
<ul className="list-group">
{items.map((l) => (
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
<span className="badge text-bg-light">{l.id}</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Language = { id: string; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged<Language>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Language> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/languages?${params.toString()}`);
}
return (
<div>
<h1>Languages</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>Code</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td><span className="badge text-bg-light">{l.id}</span></td>
<td>
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
</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={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { 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 LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: Paged<Item>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
return (
<div>
<h1 className="mb-0">Language <span className="badge text-bg-light">{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this language…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{initial && initial.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>
{initial.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 className="d-flex align-items-center gap-2 mt-2">
<span>Page {initial.page} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
aria-disabled={initial.page <= 1}
href={`/zxdb/languages/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
aria-disabled={initial.page >= totalPages}
href={`/zxdb/languages/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import LanguageDetailClient from "./LanguageDetail";
import { entriesByLanguage } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Language" };
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, 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 initial = await entriesByLanguage(id, page, 20, q || undefined);
return <LanguageDetailClient id={id} initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,15 @@
import LanguagesSearch from "./LanguagesSearch";
import { searchLanguages } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Languages" };
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchLanguages({ q, page, pageSize: 20 });
return <LanguagesSearch initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,21 @@
"use client";
import Link from "next/link";
type MT = { id: number; name: string };
export default function MachineTypeList({ items }: { items: MT[] }) {
return (
<div>
<h1>Machine Types</h1>
<ul className="list-group">
{items.map((m) => (
<li key={m.id} className="list-group-item d-flex justify-content-between align-items-center">
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
<span className="badge text-bg-light">#{m.id}</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
type MT = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged<MT>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<MT> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/machinetypes?${params.toString()}`);
}
return (
<div>
<h1>Machine Types</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id}>
<td><span className="badge text-bg-light">#{m.id}</span></td>
<td>
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
</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={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { 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 MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
const machineName = useMemo(() => {
// Prefer the name already provided by SSR items to avoid client pop-in
return initial.items.find((it) => it.machinetypeId != null && it.machinetypeName)?.machinetypeName ?? null;
}, [initial]);
return (
<div>
<h1 className="mb-0">{machineName ?? "Machine Type"} <span className="badge text-bg-light">#{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this machine type…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{initial && initial.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>
{initial.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 className="d-flex align-items-center gap-2 mt-2">
<span>Page {initial.page} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
aria-disabled={initial.page <= 1}
href={`/zxdb/machinetypes/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
aria-disabled={initial.page >= totalPages}
href={`/zxdb/machinetypes/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import MachineTypeDetailClient from "./MachineTypeDetail";
import { entriesByMachinetype } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Machine Type" };
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id);
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 initial = await entriesByMachinetype(numericId, page, 20, q || undefined);
return <MachineTypeDetailClient id={numericId} initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,14 @@
import MachineTypesSearch from "./MachineTypesSearch";
import { searchMachinetypes } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Machine Types" };
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchMachinetypes({ q, page, pageSize: 20 });
return <MachineTypesSearch initial={initial} initialQ={q} />;
}

60
src/app/zxdb/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
import Link from "next/link";
export const metadata = {
title: "ZXDB Explorer",
};
export const revalidate = 3600;
export default async function Page() {
return (
<div>
<h1 className="mb-3">ZXDB Explorer</h1>
<p className="text-secondary">Choose what you want to explore.</p>
<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>
);
}

View File

@@ -0,0 +1,366 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
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) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}
useEffect(() => {
if (initial) {
setData(initial);
setPage(initial.page);
}
}, [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);
}, [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 typeof sort); 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>
<EntryLink id={it.entryId} />
</td>
<td>
<EntryLink id={it.entryId} title={it.entryTitle} />
</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>
);
}

View 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) ?? "year_desc") as "year_desc" | "year_asc" | "title" | "entry_id_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}
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
/>
);
}

View File

@@ -1,8 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import * as Icon from "react-bootstrap-icons"; import { Navbar, Nav, Container } from "react-bootstrap";
import { Navbar, Nav, Container, Dropdown } from "react-bootstrap";
import ThemeDropdown from "@/components/ThemeDropdown"; import ThemeDropdown from "@/components/ThemeDropdown";
export default function NavbarClient() { export default function NavbarClient() {
@@ -15,6 +14,7 @@ export default function NavbarClient() {
<Nav className="me-auto mb-2 mb-lg-0"> <Nav className="me-auto mb-2 mb-lg-0">
<Link className="nav-link" href="/">Home</Link> <Link className="nav-link" href="/">Home</Link>
<Link className="nav-link" href="/registers">Registers</Link> <Link className="nav-link" href="/registers">Registers</Link>
{/*<Link className="nav-link" href="/zxdb">ZXDB</Link>*/}
</Nav> </Nav>
<ThemeDropdown /> <ThemeDropdown />

33
src/env.ts Normal file
View File

@@ -0,0 +1,33 @@
import { z } from "zod";
// Server-side environment schema (t3.gg style)
const serverSchema = z.object({
// Full MySQL connection URL, e.g. mysql://user:pass@host:3306/zxdb
ZXDB_URL: z
.string()
.url()
.refine((s) => s.startsWith("mysql://"), {
message: "ZXDB_URL must be a valid mysql:// URL",
}),
});
function formatErrors(errors: z.ZodFormattedError<Map<string, string>, string>) {
return Object.entries(errors)
.map(([name, value]) => {
if (value && "_errors" in value) {
const errs = (value as z.ZodFormattedError<string>)._errors;
return `${name}: ${errs.join(", ")}`;
}
return `${name}: invalid`;
})
.join("\n");
}
const parsed = serverSchema.safeParse(process.env);
if (!parsed.success) {
// Fail fast with helpful output in server context
console.error("❌ Invalid environment variables:\n" + formatErrors(parsed.error.format()));
throw new Error("Invalid environment variables");
}
export const env = parsed.data;

15
src/server/db.ts Normal file
View File

@@ -0,0 +1,15 @@
import mysql from "mysql2/promise";
import { drizzle } from "drizzle-orm/mysql2";
import { env } from "@/env";
// Create a singleton connection pool for the ZXDB database
const pool = mysql.createPool({
uri: env.ZXDB_URL,
connectionLimit: 10,
// Larger queries may be needed for ZXDB
maxPreparedStatements: 256,
});
export const db = drizzle(pool);
export type Db = typeof db;

1182
src/server/repo/zxdb.ts Normal file

File diff suppressed because it is too large Load Diff

216
src/server/schema/zxdb.ts Normal file
View File

@@ -0,0 +1,216 @@
import { mysqlTable, int, varchar, tinyint, char, smallint, decimal } from "drizzle-orm/mysql-core";
// Minimal subset needed for browsing/searching
export const entries = mysqlTable("entries", {
id: int("id").notNull().primaryKey(),
title: varchar("title", { length: 250 }).notNull(),
isXrated: tinyint("is_xrated").notNull(),
machinetypeId: tinyint("machinetype_id"),
maxPlayers: tinyint("max_players").notNull().default(1),
languageId: char("language_id", { length: 2 }),
genretypeSpotId: tinyint("spot_genretype_id"),
genretypeId: tinyint("genretype_id"),
availabletypeId: char("availabletype_id", { length: 1 }),
withoutLoadScreen: tinyint("without_load_screen").notNull(),
withoutInlay: tinyint("without_inlay").notNull(),
issueId: int("issue_id"),
});
// Helper table created by ZXDB_help_search.sql
export const searchByTitles = mysqlTable("search_by_titles", {
entryTitle: varchar("entry_title", { length: 250 }).notNull(),
entryId: int("entry_id").notNull(),
});
export type Entry = typeof entries.$inferSelect;
// ZXDB labels (people/companies/teams)
export const labels = mysqlTable("labels", {
id: int("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
labeltypeId: char("labeltype_id", { length: 1 }),
});
// Helper table for names search
export const searchByNames = mysqlTable("search_by_names", {
labelName: varchar("label_name", { length: 100 }).notNull(),
labelId: int("label_id").notNull(),
});
// Helper: entries by authors
export const searchByAuthors = mysqlTable("search_by_authors", {
labelId: int("label_id").notNull(),
entryId: int("entry_id").notNull(),
});
// Helper: entries by publishers
export const searchByPublishers = mysqlTable("search_by_publishers", {
labelId: int("label_id").notNull(),
entryId: int("entry_id").notNull(),
});
// Relations tables
export const authors = mysqlTable("authors", {
entryId: int("entry_id").notNull(),
labelId: int("label_id").notNull(),
teamId: int("team_id"),
});
export const publishers = mysqlTable("publishers", {
entryId: int("entry_id").notNull(),
labelId: int("label_id").notNull(),
});
// Lookups
export const languages = mysqlTable("languages", {
id: char("id", { length: 2 }).notNull().primaryKey(),
// Column name in DB is `text`; map to `name` property for app ergonomics
name: varchar("text", { length: 100 }).notNull(),
});
export const machinetypes = mysqlTable("machinetypes", {
id: tinyint("id").notNull().primaryKey(),
// Column name in DB is `text`
name: varchar("text", { length: 50 }).notNull(),
});
export const genretypes = mysqlTable("genretypes", {
id: tinyint("id").notNull().primaryKey(),
// Column name in DB is `text`
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(),
// Column name in DB is `text`
name: varchar("text", { length: 50 }).notNull(),
});
export const files = mysqlTable("files", {
id: int("id").notNull().primaryKey(),
labelId: int("label_id"),
issueId: int("issue_id"),
toolId: int("tool_id"),
fileLink: varchar("file_link", { length: 250 }).notNull(),
fileDate: varchar("file_date", { length: 50 }),
fileSize: int("file_size"),
fileMd5: varchar("file_md5", { length: 32 }),
filetypeId: tinyint("filetype_id").notNull(),
comments: varchar("comments", { length: 250 }),
});
export const schemetypes = mysqlTable("schemetypes", {
id: char("id", { length: 2 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const sourcetypes = mysqlTable("sourcetypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const casetypes = mysqlTable("casetypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
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(),
link: varchar("link", { length: 150 }).notNull(),
admin: varchar("admin", { length: 150 }).notNull(),
magazineId: smallint("magazine_id"),
});
// ---- Aliases (alternative titles per entry/release/language)
export const aliases = mysqlTable("aliases", {
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq").notNull().default(0),
languageId: char("language_id", { length: 2 }).notNull(),
title: varchar("title", { length: 250 }).notNull(),
});
// `releases` are identified by (entry_id, release_seq)
export const releases = mysqlTable("releases", {
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq").notNull(),
releaseYear: smallint("release_year"),
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)
export const downloads = mysqlTable("downloads", {
id: int("id").notNull().primaryKey(),
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq").notNull().default(0),
fileLink: varchar("file_link", { length: 250 }).notNull(),
fileDate: varchar("file_date", { length: 50 }),
fileSize: int("file_size"),
fileMd5: varchar("file_md5", { length: 32 }),
filetypeId: tinyint("filetype_id").notNull(),
scrBorder: tinyint("scr_border").notNull().default(7),
languageId: char("language_id", { length: 2 }),
isDemo: tinyint("is_demo").notNull(),
schemetypeId: char("schemetype_id", { length: 2 }),
machinetypeId: tinyint("machinetype_id"),
fileCode: varchar("file_code", { length: 50 }),
fileBarcode: varchar("file_barcode", { length: 50 }),
fileDl: varchar("file_dl", { length: 150 }),
casetypeId: char("casetype_id", { length: 1 }),
sourcetypeId: char("sourcetype_id", { length: 1 }),
releaseYear: smallint("release_year"),
comments: varchar("comments", { length: 250 }),
});
// ---- Web references (external links tied to entries)
export const webrefs = mysqlTable("webrefs", {
entryId: int("entry_id").notNull(),
link: varchar("link", { length: 200 }).notNull(),
websiteId: tinyint("website_id").notNull(),
languageId: char("language_id", { length: 2 }).notNull(),
});
export const websites = mysqlTable("websites", {
id: tinyint("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
comments: varchar("comments", { length: 100 }),
link: varchar("link", { length: 100 }),
linkMask: varchar("link_mask", { length: 100 }),
});
// 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(),
});

View File

@@ -10,13 +10,13 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
// Footnote multiline state // Footnote multiline state
let inFootnote = false; let inFootnote = false;
let footnoteBaseIndent = 0; let footnoteBaseIndent = 0;
let footnoteTarget: 'global' | 'access' | null = null; // let footnoteTarget: 'global' | 'access' | null = null;
let currentFootnote: Note | null = null; let currentFootnote: Note | null = null;
const endFootnoteIfActive = () => { const endFootnoteIfActive = () => {
inFootnote = false; inFootnote = false;
footnoteBaseIndent = 0; footnoteBaseIndent = 0;
footnoteTarget = null; // footnoteTarget = null;
currentFootnote = null; currentFootnote = null;
}; };
@@ -89,10 +89,10 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
const note: Note = { ref: noteMatch[1], text: noteMatch[2] }; const note: Note = { ref: noteMatch[1], text: noteMatch[2] };
if (currentAccess) { if (currentAccess) {
accessData.notes.push(note); accessData.notes.push(note);
footnoteTarget = 'access'; // footnoteTarget = 'access';
} else { } else {
reg.notes.push(note); reg.notes.push(note);
footnoteTarget = 'global'; // footnoteTarget = 'global';
} }
currentFootnote = note; currentFootnote = note;
inFootnote = true; inFootnote = true;