Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1a04a89cf | |||
| 279abac91a | |||
| 2bade1825c | |||
| 07478b280c | |||
| 89001f53da | |||
| 18cf0cc140 | |||
| 53a1821547 | |||
| 24cb74ac14 | |||
| 363c8bc121 | |||
| 038c60338b | |||
| f563b41792 | |||
| fd4c0f8963 | |||
| 285c7da87c | |||
| 761810901f | |||
| f507d51c61 | |||
| 240936a850 | |||
| ddbf72ea52 | |||
| 3ef3a16bc0 | |||
| 54cfe4f175 | |||
| ad77b47117 | |||
| 3fe6f980c6 | |||
| dbbad09b1b | |||
| 4222eba8ba | |||
| 79aabd9b62 | |||
| 417fd997a7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "ZXDB"]
|
||||||
|
path = ZXDB
|
||||||
|
url = https://github.com/zxdb/ZXDB
|
||||||
1
.junie/guidelines.md
Symbolic link
1
.junie/guidelines.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../AGENTS.md
|
||||||
157
AGENTS.md
Normal file
157
AGENTS.md
Normal 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, cross‑linked 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 register’s 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/`: Zod‑validated 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` server‑render initial content for fast first paint, with ISR (`export const revalidate = 3600`) on non‑search 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 cross‑linked and server‑renders 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`
|
||||||
87
README.md
87
README.md
@@ -1,36 +1,77 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
Spectrum Next Explorer
|
||||||
|
|
||||||
## Getting Started
|
A Next.js application for exploring the Spectrum Next ecosystem. It ships with:
|
||||||
|
|
||||||
First, run the development server:
|
- Register Explorer: parsed from `data/nextreg.txt`, with real‑time search and deep links
|
||||||
|
- ZXDB Explorer: a deep, cross‑linked browser for entries, labels, genres, languages, and machine types backed by a MySQL ZXDB instance
|
||||||
|
- Bootstrap 5 theme with light/dark support
|
||||||
|
|
||||||
```bash
|
Quick start
|
||||||
npm run dev
|
- Prerequisites: Node.js 20+, pnpm (recommended), access to a MySQL server for ZXDB (optional for Registers)
|
||||||
# or
|
- Install dependencies:
|
||||||
yarn dev
|
- `pnpm install`
|
||||||
# or
|
- Run in development (Turbopack, port 4000):
|
||||||
pnpm dev
|
- `pnpm dev` then open http://localhost:4000
|
||||||
# or
|
- Build and start (production):
|
||||||
bun dev
|
- `pnpm build`
|
||||||
```
|
- `pnpm start` (defaults to http://localhost:3000)
|
||||||
|
- Lint:
|
||||||
|
- `pnpm lint`
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Project scripts (package.json)
|
||||||
|
- `dev`: `PORT=4000 next dev --turbopack`
|
||||||
|
- `build`: `next build --turbopack`
|
||||||
|
- `start`: `next start`
|
||||||
|
- `deploy-test`: push to `test.explorer.specnext.dev`
|
||||||
|
- `deploy-prod`: push to `explorer.specnext.dev`
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
Routes
|
||||||
|
- `/` — Home
|
||||||
|
- `/registers` — Register Explorer
|
||||||
|
- `/zxdb` — ZXDB Explorer (search + filters)
|
||||||
|
- `/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
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
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.
|
||||||
|
|
||||||
## Learn More
|
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 read‑only role/user (recommended):
|
||||||
|
- Example (see `bin/import_mysql.sh`):
|
||||||
|
- Create role `zxdb_readonly`
|
||||||
|
- Grant `SELECT, SHOW VIEW` on database `zxdb`
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
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.
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
3) Run the app
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`.
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
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`
|
||||||
|
|
||||||
## Deploy on Vercel
|
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 server‑render initial content and use ISR (`revalidate = 3600`) for fast time‑to‑content; index pages avoid a blocking first client fetch.
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
Further reading
|
||||||
|
- ZXDB details and API usage: `docs/ZXDB.md`
|
||||||
|
- Agent/developer workflow and commit guidelines: `AGENTS.md`
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
License
|
||||||
|
- See `LICENSE.txt` for details.
|
||||||
|
|||||||
1
ZXDB
Submodule
1
ZXDB
Submodule
Submodule ZXDB added at 3784c91bdd
12
bin/import_mysql.sh
Normal file
12
bin/import_mysql.sh
Normal 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
110
docs/ZXDB.md
Normal 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 built‑in API and UI for software discovery.
|
||||||
|
|
||||||
|
## What is ZXDB?
|
||||||
|
|
||||||
|
ZXDB ( https://github.com/zxdb/ZXDB )is a community‑maintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in read‑only mode and expose a fast, cross‑linked 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 read‑only 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 read‑only 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.
|
||||||
|
|
||||||
|
Cross‑linking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched.
|
||||||
|
|
||||||
|
Performance: Detail and index pages are server‑rendered with initial data and use ISR (`revalidate = 3600`) to reduce time‑to‑first‑content. 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 (free‑text 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 server‑rendering 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).
|
||||||
30
docs/architecture.md
Normal file
30
docs/architecture.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Architecture
|
||||||
|
|
||||||
|
Overview
|
||||||
|
- Framework: Next.js App Router (React 19)
|
||||||
|
- Styling: Bootstrap 5 with React-Bootstrap components and project SASS overrides
|
||||||
|
- Theming: Light/Dark theme set via data-bs-theme on <html>, initialized from a cookie or system preference in `src/app/layout.tsx`
|
||||||
|
|
||||||
|
Key paths
|
||||||
|
- App entry/layout: `src/app/layout.tsx`
|
||||||
|
- Global styles: `src/scss/nbn.scss` (imports Bootstrap, a Bootswatch-like layer, and project styles)
|
||||||
|
- Navbar: `src/components/Navbar.tsx`
|
||||||
|
- Register Explorer: `src/app/registers/*`
|
||||||
|
- Register parsing utilities: `src/utils/register_parser.ts` and `src/utils/register_parsers/*`
|
||||||
|
- Data: `data/nextreg.txt`
|
||||||
|
|
||||||
|
Styling & SASS
|
||||||
|
- `src/scss/nbn.scss` imports Bootstrap and local overrides in this order:
|
||||||
|
1. `variables` (custom Bootstrap variables)
|
||||||
|
2. Bootstrap core
|
||||||
|
3. `bootswatch` (theme tweaks)
|
||||||
|
4. `explorer` (project-specific styles)
|
||||||
|
|
||||||
|
Theming bootstrap
|
||||||
|
- On the server, `layout.tsx` reads the `NBN-theme` cookie (light/dark) and sets `data-bs-theme` on the HTML element.
|
||||||
|
- On the client, an inline script in the head ensures no flash of incorrect theme by immediately setting the attribute based on cookie or user preference.
|
||||||
|
|
||||||
|
Development scripts
|
||||||
|
- Dev: `pnpm dev` (port 4000 with Turbopack)
|
||||||
|
- Build: `pnpm build`
|
||||||
|
- Start: `pnpm start` (defaults to port 3000)
|
||||||
30
docs/getting-started.md
Normal file
30
docs/getting-started.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Getting Started
|
||||||
|
|
||||||
|
This project is a Next.js app for exploring the Spectrum Next hardware. It uses the App Router, Bootstrap 5, and React-Bootstrap.
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
- Node.js 20 or newer
|
||||||
|
- pnpm (recommended) or npm/yarn
|
||||||
|
|
||||||
|
Install
|
||||||
|
- pnpm install
|
||||||
|
- or: npm install
|
||||||
|
|
||||||
|
Run in development
|
||||||
|
- The dev server runs on port 4000 using Turbopack
|
||||||
|
- Command: pnpm dev
|
||||||
|
- Then open: http://localhost:4000
|
||||||
|
|
||||||
|
Build and start (production)
|
||||||
|
- Build: pnpm build
|
||||||
|
- Start: pnpm start
|
||||||
|
- Default start port: http://localhost:3000
|
||||||
|
|
||||||
|
Lint
|
||||||
|
- pnpm lint
|
||||||
|
|
||||||
|
Deployment shortcuts
|
||||||
|
- Two scripts are available in package.json:
|
||||||
|
- pnpm deploy-test: push the current branch to test.explorer.specnext.dev
|
||||||
|
- pnpm deploy-prod: push the current branch to explorer.specnext.dev
|
||||||
|
Ensure the corresponding Git remotes are configured locally before using these.
|
||||||
9
docs/index.md
Normal file
9
docs/index.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Spectrum Next Explorer — Documentation
|
||||||
|
|
||||||
|
Welcome to the Spectrum Next Explorer docs. This site provides an overview of the project, how to develop and contribute, and details about key features like the Register Explorer and its search/deep‑linking capability.
|
||||||
|
|
||||||
|
- Getting Started: ./getting-started.md
|
||||||
|
- Architecture: ./architecture.md
|
||||||
|
- Register Explorer: ./registers.md
|
||||||
|
|
||||||
|
If you’re browsing on GitHub, the main README also links to these documents.
|
||||||
20
docs/registers.md
Normal file
20
docs/registers.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Register Explorer
|
||||||
|
|
||||||
|
Overview
|
||||||
|
The Register Explorer lets you browse and search Spectrum Next registers parsed from `data/nextreg.txt`. Each register page shows address, access details, bit tables, and notes.
|
||||||
|
|
||||||
|
Searching
|
||||||
|
- Use the search input to filter registers in real time.
|
||||||
|
- The query is case‑insensitive and matches a combined `search` field per register (name, address, and keywords).
|
||||||
|
|
||||||
|
Deep links (query string)
|
||||||
|
- The search box syncs with the `q` query parameter so searches are shareable.
|
||||||
|
- Example: `/registers?q=vram`
|
||||||
|
- When you open this URL, the search box is pre‑filled with `vram` and the list is filtered immediately.
|
||||||
|
- Clearing the search removes `q` from the URL.
|
||||||
|
|
||||||
|
Implementation notes
|
||||||
|
- Component: `src/app/registers/RegisterBrowser.tsx`
|
||||||
|
- Uses Next.js navigation hooks: `useSearchParams`, `useRouter`, `usePathname`.
|
||||||
|
- On mount and when the URL changes, the component reads `q` and updates local state.
|
||||||
|
- On input change, the component updates state and calls `router.replace()` to keep the URL in sync without scrolling.
|
||||||
14
drizzle.config.ts
Normal file
14
drizzle.config.ts
Normal 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;
|
||||||
18
example.env
Normal file
18
example.env
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Base HTTP locations for CDN sources used by downloads.file_link
|
||||||
|
# When file_link starts with /zxdb, it will be fetched from ZXDB_FILEPATH
|
||||||
|
ZXDB_FILEPATH=https://zxdbfiles.com/
|
||||||
|
|
||||||
|
# When file_link starts with /public, it will be fetched from WOS_FILEPATH
|
||||||
|
# Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
|
||||||
|
WOS_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/
|
||||||
|
|
||||||
|
# Local cache root where files will be mirrored (without the leading slash)
|
||||||
|
CDN_CACHE=/mnt/files/zxfiles
|
||||||
@@ -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;
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -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,26 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"next": "~15.5.7",
|
"dotenv": "^17.2.3",
|
||||||
|
"dotenv-expand": "^11.0.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",
|
"drizzle-kit": "^0.30.6",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "15.5.4",
|
"eslint-config-next": "15.5.4",
|
||||||
"sass": "^1.94.2"
|
"sass": "^1.97.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1102
pnpm-lock.yaml
generated
1102
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
10
src/app/api/zxdb/availabletypes/route.ts
Normal file
10
src/app/api/zxdb/availabletypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listAvailabletypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listAvailabletypes();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
10
src/app/api/zxdb/casetypes/route.ts
Normal file
10
src/app/api/zxdb/casetypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listCasetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listCasetypes();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
10
src/app/api/zxdb/currencies/route.ts
Normal file
10
src/app/api/zxdb/currencies/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listCurrencies } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listCurrencies();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
33
src/app/api/zxdb/entries/[id]/route.ts
Normal file
33
src/app/api/zxdb/entries/[id]/route.ts
Normal 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";
|
||||||
10
src/app/api/zxdb/filetypes/route.ts
Normal file
10
src/app/api/zxdb/filetypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listFiletypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listFiletypes();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
31
src/app/api/zxdb/genres/[id]/route.ts
Normal file
31
src/app/api/zxdb/genres/[id]/route.ts
Normal 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";
|
||||||
13
src/app/api/zxdb/genres/route.ts
Normal file
13
src/app/api/zxdb/genres/route.ts
Normal 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";
|
||||||
51
src/app/api/zxdb/labels/[id]/route.ts
Normal file
51
src/app/api/zxdb/labels/[id]/route.ts
Normal 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";
|
||||||
30
src/app/api/zxdb/labels/search/route.ts
Normal file
30
src/app/api/zxdb/labels/search/route.ts
Normal 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";
|
||||||
31
src/app/api/zxdb/languages/[id]/route.ts
Normal file
31
src/app/api/zxdb/languages/[id]/route.ts
Normal 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";
|
||||||
13
src/app/api/zxdb/languages/route.ts
Normal file
13
src/app/api/zxdb/languages/route.ts
Normal 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";
|
||||||
31
src/app/api/zxdb/machinetypes/[id]/route.ts
Normal file
31
src/app/api/zxdb/machinetypes/[id]/route.ts
Normal 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";
|
||||||
13
src/app/api/zxdb/machinetypes/route.ts
Normal file
13
src/app/api/zxdb/machinetypes/route.ts
Normal 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";
|
||||||
48
src/app/api/zxdb/releases/search/route.ts
Normal file
48
src/app/api/zxdb/releases/search/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { searchReleases } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().optional(),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
|
year: z.coerce.number().int().optional(),
|
||||||
|
sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(),
|
||||||
|
dLanguageId: z.string().trim().length(2).optional(),
|
||||||
|
dMachinetypeId: z.coerce.number().int().positive().optional(),
|
||||||
|
filetypeId: z.coerce.number().int().positive().optional(),
|
||||||
|
schemetypeId: z.string().trim().length(2).optional(),
|
||||||
|
sourcetypeId: z.string().trim().length(1).optional(),
|
||||||
|
casetypeId: z.string().trim().length(1).optional(),
|
||||||
|
isDemo: z.coerce.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const parsed = querySchema.safeParse({
|
||||||
|
q: searchParams.get("q") ?? undefined,
|
||||||
|
page: searchParams.get("page") ?? undefined,
|
||||||
|
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||||
|
year: searchParams.get("year") ?? undefined,
|
||||||
|
sort: searchParams.get("sort") ?? undefined,
|
||||||
|
dLanguageId: searchParams.get("dLanguageId") ?? undefined,
|
||||||
|
dMachinetypeId: searchParams.get("dMachinetypeId") ?? undefined,
|
||||||
|
filetypeId: searchParams.get("filetypeId") ?? undefined,
|
||||||
|
schemetypeId: searchParams.get("schemetypeId") ?? undefined,
|
||||||
|
sourcetypeId: searchParams.get("sourcetypeId") ?? undefined,
|
||||||
|
casetypeId: searchParams.get("casetypeId") ?? undefined,
|
||||||
|
isDemo: searchParams.get("isDemo") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return new Response(JSON.stringify({ error: parsed.error.flatten() }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const data = await searchReleases(parsed.data);
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
10
src/app/api/zxdb/roletypes/route.ts
Normal file
10
src/app/api/zxdb/roletypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listRoletypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listRoletypes();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
10
src/app/api/zxdb/schemetypes/route.ts
Normal file
10
src/app/api/zxdb/schemetypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listSchemetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listSchemetypes();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
48
src/app/api/zxdb/search/route.ts
Normal file
48
src/app/api/zxdb/search/route.ts
Normal 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";
|
||||||
10
src/app/api/zxdb/sourcetypes/route.ts
Normal file
10
src/app/api/zxdb/sourcetypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listSourcetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listSourcetypes();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
258
src/app/zxdb/ZxdbExplorer.tsx
Normal file
258
src/app/zxdb/ZxdbExplorer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/zxdb/components/EntryLink.tsx
Normal file
18
src/app/zxdb/components/EntryLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
323
src/app/zxdb/entries/EntriesExplorer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
410
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/zxdb/entries/[id]/page.tsx
Normal file
16
src/app/zxdb/entries/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
43
src/app/zxdb/entries/page.tsx
Normal file
43
src/app/zxdb/entries/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import EntriesExplorer from "./EntriesExplorer";
|
||||||
|
import { listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "ZXDB Entries",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const genreId = (Array.isArray(sp.genreId) ? sp.genreId[0] : sp.genreId) ?? "";
|
||||||
|
const languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? "";
|
||||||
|
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? "";
|
||||||
|
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/zxdb/genres/GenreList.tsx
Normal file
21
src/app/zxdb/genres/GenreList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/app/zxdb/genres/GenresSearch.tsx
Normal file
91
src/app/zxdb/genres/GenresSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/zxdb/genres/[id]/GenreDetail.tsx
Normal file
93
src/app/zxdb/genres/[id]/GenreDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/zxdb/genres/[id]/page.tsx
Normal file
16
src/app/zxdb/genres/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
14
src/app/zxdb/genres/page.tsx
Normal file
14
src/app/zxdb/genres/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
79
src/app/zxdb/issues/[id]/page.tsx
Normal file
79
src/app/zxdb/issues/[id]/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getIssue } from "@/server/repo/zxdb";
|
||||||
|
import EntryLink from "@/app/zxdb/components/EntryLink";
|
||||||
|
|
||||||
|
export const metadata = { title: "ZXDB Issue" };
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const issueId = Number(id);
|
||||||
|
if (!Number.isFinite(issueId) || issueId <= 0) return notFound();
|
||||||
|
|
||||||
|
const issue = await getIssue(issueId);
|
||||||
|
if (!issue) return notFound();
|
||||||
|
|
||||||
|
const ym = [issue.dateYear ?? "", issue.dateMonth ? String(issue.dateMonth).padStart(2, "0") : ""].filter(Boolean).join("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 d-flex gap-2 flex-wrap">
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href={`/zxdb/magazines/${issue.magazine.id}`}>← Back to magazine</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link>
|
||||||
|
{issue.linkMask && (
|
||||||
|
<a className="btn btn-outline-secondary btn-sm" href={issue.linkMask} target="_blank" rel="noreferrer">Issue link</a>
|
||||||
|
)}
|
||||||
|
{issue.archiveMask && (
|
||||||
|
<a className="btn btn-outline-secondary btn-sm" href={issue.archiveMask} target="_blank" rel="noreferrer">Archive</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-1">{issue.magazine.title}</h1>
|
||||||
|
<div className="text-secondary mb-3">
|
||||||
|
Issue: {ym || issue.id}{issue.volume != null ? ` · Vol ${issue.volume}` : ""}{issue.number != null ? ` · No ${issue.number}` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(issue.special || issue.supplement) && (
|
||||||
|
<div className="mb-3">
|
||||||
|
{issue.special && <div><strong>Special:</strong> {issue.special}</div>}
|
||||||
|
{issue.supplement && <div><strong>Supplement:</strong> {issue.supplement}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="h5 mt-4">References</h2>
|
||||||
|
{issue.refs.length === 0 ? (
|
||||||
|
<div className="text-secondary">No references recorded.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 80 }}>Page</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th>Reference</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{issue.refs.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{r.page}</td>
|
||||||
|
<td>{r.typeName}</td>
|
||||||
|
<td>
|
||||||
|
{r.entryId ? (
|
||||||
|
<EntryLink id={r.entryId} title={r.entryTitle ?? undefined} />
|
||||||
|
) : r.labelId ? (
|
||||||
|
<Link href={`/zxdb/labels/${r.labelId}`}>{r.labelName ?? r.labelId}</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/app/zxdb/labels/LabelsSearch.tsx
Normal file
97
src/app/zxdb/labels/LabelsSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
126
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/zxdb/labels/[id]/page.tsx
Normal file
24
src/app/zxdb/labels/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
15
src/app/zxdb/labels/page.tsx
Normal file
15
src/app/zxdb/labels/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
21
src/app/zxdb/languages/LanguageList.tsx
Normal file
21
src/app/zxdb/languages/LanguageList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/app/zxdb/languages/LanguagesSearch.tsx
Normal file
91
src/app/zxdb/languages/LanguagesSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/zxdb/languages/[id]/LanguageDetail.tsx
Normal file
93
src/app/zxdb/languages/[id]/LanguageDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/zxdb/languages/[id]/page.tsx
Normal file
15
src/app/zxdb/languages/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
15
src/app/zxdb/languages/page.tsx
Normal file
15
src/app/zxdb/languages/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
21
src/app/zxdb/machinetypes/MachineTypeList.tsx
Normal file
21
src/app/zxdb/machinetypes/MachineTypeList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal file
93
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
Normal file
105
src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/zxdb/machinetypes/[id]/page.tsx
Normal file
15
src/app/zxdb/machinetypes/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
14
src/app/zxdb/machinetypes/page.tsx
Normal file
14
src/app/zxdb/machinetypes/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
85
src/app/zxdb/magazines/[id]/page.tsx
Normal file
85
src/app/zxdb/magazines/[id]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getMagazine } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = { title: "ZXDB Magazine" };
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const magazineId = Number(id);
|
||||||
|
if (!Number.isFinite(magazineId) || magazineId <= 0) return notFound();
|
||||||
|
|
||||||
|
const mag = await getMagazine(magazineId);
|
||||||
|
if (!mag) return notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">{mag.title}</h1>
|
||||||
|
<div className="text-secondary mb-3">Language: {mag.languageId}</div>
|
||||||
|
|
||||||
|
<div className="mb-3 d-flex gap-2 flex-wrap">
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">← Back to list</Link>
|
||||||
|
{mag.linkSite && (
|
||||||
|
<a className="btn btn-outline-secondary btn-sm" href={mag.linkSite} target="_blank" rel="noreferrer">
|
||||||
|
Official site
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="h5 mt-4">Issues</h2>
|
||||||
|
{mag.issues.length === 0 ? (
|
||||||
|
<div className="text-secondary">No issues found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 200 }}>Issue</th>
|
||||||
|
<th style={{ width: 100 }}>Volume</th>
|
||||||
|
<th style={{ width: 100 }}>Number</th>
|
||||||
|
<th>Special</th>
|
||||||
|
<th>Supplement</th>
|
||||||
|
<th style={{ width: 100 }}>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mag.issues.map((i) => (
|
||||||
|
<tr key={i.id}>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/issues/${i.id}`} className="link-underline link-underline-opacity-0">
|
||||||
|
{i.dateYear ?? ""}
|
||||||
|
{i.dateMonth ? `/${String(i.dateMonth).padStart(2, "0")}` : ""}
|
||||||
|
{" "}
|
||||||
|
<span className="text-secondary">(open issue)</span>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{i.volume ?? ""}</td>
|
||||||
|
<td>{i.number ?? ""}</td>
|
||||||
|
<td>{i.special ?? ""}</td>
|
||||||
|
<td>{i.supplement ?? ""}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
{i.linkMask && (
|
||||||
|
<a className="btn btn-outline-secondary btn-sm" href={i.linkMask} target="_blank" rel="noreferrer" title="Link">
|
||||||
|
<span className="bi bi-link-45deg" aria-hidden />
|
||||||
|
<span className="visually-hidden">Link</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{i.archiveMask && (
|
||||||
|
<a className="btn btn-outline-secondary btn-sm" href={i.archiveMask} target="_blank" rel="noreferrer" title="Archive">
|
||||||
|
<span className="bi bi-archive" aria-hidden />
|
||||||
|
<span className="visually-hidden">Archive</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/app/zxdb/magazines/page.tsx
Normal file
81
src/app/zxdb/magazines/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { listMagazines } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = { title: "ZXDB Magazines" };
|
||||||
|
|
||||||
|
// 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 data = await listMagazines({ q, page, pageSize: 20 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-3">Magazines</h1>
|
||||||
|
|
||||||
|
<form className="mb-3" action="/zxdb/magazines" method="get">
|
||||||
|
<div className="input-group">
|
||||||
|
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
|
||||||
|
<button className="btn btn-outline-secondary" type="submit">
|
||||||
|
<span className="bi bi-search" aria-hidden />
|
||||||
|
<span className="visually-hidden">Search</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="list-group">
|
||||||
|
{data.items.map((m) => (
|
||||||
|
<Link key={m.id} className="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href={`/zxdb/magazines/${m.id}`}>
|
||||||
|
<span>
|
||||||
|
{m.title}
|
||||||
|
<span className="text-secondary ms-2">({m.languageId})</span>
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-secondary rounded-pill" title="Issues">
|
||||||
|
{m.issueCount}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ page, pageSize, total, q }: { page: number; pageSize: number; total: number; q: string }) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
const makeHref = (p: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
params.set("page", String(p));
|
||||||
|
return `/zxdb/magazines?${params.toString()}`;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<nav className="mt-3" aria-label="Pagination">
|
||||||
|
<ul className="pagination">
|
||||||
|
<li className={`page-item ${page <= 1 ? "disabled" : ""}`}>
|
||||||
|
<Link className="page-link" href={makeHref(Math.max(1, page - 1))}>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="page-item disabled">
|
||||||
|
<span className="page-link">Page {page} of {totalPages}</span>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item ${page >= totalPages ? "disabled" : ""}`}>
|
||||||
|
<Link className="page-link" href={makeHref(Math.min(totalPages, page + 1))}>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/app/zxdb/page.tsx
Normal file
76
src/app/zxdb/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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 className="col-sm-6 col-lg-4">
|
||||||
|
<Link href="/zxdb/magazines" 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-journal-text" style={{ fontSize: 28 }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">Magazines</h5>
|
||||||
|
<div className="card-text text-secondary">Browse magazines and their issues</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h2 className="h5 mb-2">Categories</h2>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
366
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/zxdb/releases/page.tsx
Normal file
41
src/app/zxdb/releases/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import ReleasesExplorer from "./ReleasesExplorer";
|
||||||
|
import { searchReleases } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "ZXDB Releases",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||||
|
const yearStr = (Array.isArray(sp.year) ? sp.year[0] : sp.year) ?? "";
|
||||||
|
const year = yearStr ? Number(yearStr) : undefined;
|
||||||
|
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
33
src/env.ts
Normal 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
15
src/server/db.ts
Normal 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;
|
||||||
1384
src/server/repo/zxdb.ts
Normal file
1384
src/server/repo/zxdb.ts
Normal file
File diff suppressed because it is too large
Load Diff
344
src/server/schema/zxdb.ts
Normal file
344
src/server/schema/zxdb.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
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),
|
||||||
|
// DB allows NULLs on many of these
|
||||||
|
languageId: char("language_id", { length: 2 }),
|
||||||
|
genretypeId: tinyint("genretype_id"),
|
||||||
|
genretypeSpotId: tinyint("spot_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(),
|
||||||
|
countryId: char("country_id", { length: 2 }),
|
||||||
|
country2Id: char("country2_id", { length: 2 }),
|
||||||
|
fromId: int("from_id"),
|
||||||
|
ownerId: int("owner_id"),
|
||||||
|
wasRenamed: tinyint("was_renamed").notNull().default(0),
|
||||||
|
deceased: varchar("deceased", { length: 200 }),
|
||||||
|
linkWikipedia: varchar("link_wikipedia", { length: 200 }),
|
||||||
|
linkSite: varchar("link_site", { length: 200 }),
|
||||||
|
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"),
|
||||||
|
// Present in schema; sequence of the author for a given entry
|
||||||
|
authorSeq: smallint("author_seq").notNull().default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Magazines and Issues (subset used by the app) ----
|
||||||
|
export const magazines = mysqlTable("magazines", {
|
||||||
|
id: smallint("id").notNull().primaryKey(),
|
||||||
|
// ZXDB column is `name`
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
countryId: char("country_id", { length: 2 }).notNull(),
|
||||||
|
languageId: char("language_id", { length: 2 }).notNull(),
|
||||||
|
linkSite: varchar("link_site", { length: 200 }),
|
||||||
|
magtypeId: char("magtype_id", { length: 1 }).notNull(),
|
||||||
|
topicId: int("topic_id"),
|
||||||
|
linkMask: varchar("link_mask", { length: 250 }),
|
||||||
|
archiveMask: varchar("archive_mask", { length: 250 }),
|
||||||
|
translationMask: varchar("translation_mask", { length: 250 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const issues = mysqlTable("issues", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
magazineId: smallint("magazine_id").notNull(),
|
||||||
|
dateYear: smallint("date_year"),
|
||||||
|
dateMonth: smallint("date_month"),
|
||||||
|
dateDay: smallint("date_day"),
|
||||||
|
volume: smallint("volume"),
|
||||||
|
number: smallint("number"),
|
||||||
|
special: varchar("special", { length: 100 }),
|
||||||
|
supplement: varchar("supplement", { length: 100 }),
|
||||||
|
linkMask: varchar("link_mask", { length: 250 }),
|
||||||
|
archiveMask: varchar("archive_mask", { length: 250 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Additional ZXDB schema coverage (lookups and content) ----
|
||||||
|
|
||||||
|
export const articletypes = mysqlTable("articletypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const articles = mysqlTable("articles", {
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
link: varchar("link", { length: 200 }).notNull(),
|
||||||
|
articletypeId: char("articletype_id", { length: 1 }).notNull(),
|
||||||
|
title: varchar("title", { length: 200 }),
|
||||||
|
languageId: char("language_id", { length: 2 }).notNull(),
|
||||||
|
writer: varchar("writer", { length: 200 }),
|
||||||
|
dateYear: smallint("date_year"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categories = mysqlTable("categories", {
|
||||||
|
id: smallint("id").notNull().primaryKey(),
|
||||||
|
// DB column `text`
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const contenttypes = mysqlTable("contenttypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const contents = mysqlTable("contents", {
|
||||||
|
// ZXDB contents table does not have its own `id`; natural key is (issue_id, page_from, page_to, label_id, entry_id)
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
labelId: int("label_id"),
|
||||||
|
issueId: int("issue_id").notNull(),
|
||||||
|
contenttypeId: char("contenttype_id", { length: 1 }).notNull(),
|
||||||
|
pageFrom: smallint("page_from"),
|
||||||
|
pageTo: smallint("page_to"),
|
||||||
|
title: varchar("title", { length: 200 }),
|
||||||
|
dateYear: smallint("date_year"),
|
||||||
|
rating: tinyint("rating"),
|
||||||
|
comments: varchar("comments", { length: 250 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const extensions = mysqlTable("extensions", {
|
||||||
|
ext: varchar("ext", { length: 15 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const features = mysqlTable("features", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
name: varchar("name", { length: 150 }).notNull(),
|
||||||
|
version: tinyint("version").notNull().default(0),
|
||||||
|
labelId: int("label_id"),
|
||||||
|
label2Id: int("label2_id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tooltypes = mysqlTable("tooltypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tools = mysqlTable("tools", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
|
languageId: char("language_id", { length: 2 }),
|
||||||
|
tooltypeId: char("tooltype_id", { length: 1 }),
|
||||||
|
link: varchar("link", { length: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Magazine references (per-issue references to entries/labels/topics) ----
|
||||||
|
export const referencetypes = mysqlTable("referencetypes", {
|
||||||
|
id: tinyint("id").notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const magrefs = mysqlTable("magrefs", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
referencetypeId: tinyint("referencetype_id").notNull(),
|
||||||
|
entryId: int("entry_id"),
|
||||||
|
labelId: int("label_id"),
|
||||||
|
topicId: int("topic_id"),
|
||||||
|
issueId: int("issue_id").notNull(),
|
||||||
|
page: smallint("page").notNull().default(0),
|
||||||
|
isOriginal: tinyint("is_original").notNull().default(0),
|
||||||
|
scoreGroup: varchar("score_group", { length: 100 }).notNull().default(""),
|
||||||
|
reviewId: int("review_id"),
|
||||||
|
awardId: tinyint("award_id"),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user