Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d02adbed | |||
| 9bb0a18695 | |||
| 89d48edbd9 | |||
| 0b0dced512 | |||
| e94492eab6 | |||
| 6f7ffa899d | |||
| 84dee2710c | |||
| 5130a72641 | |||
| 964b48abf1 | |||
| d9f55c3eb6 | |||
| 06ddeba9bb | |||
| fb206734db | |||
| e2f6aac856 | |||
| 3e13da5552 | |||
| 0594b34c62 | |||
| 5d140a45a7 | |||
| 208a06c351 | |||
| 686e057bb4 | |||
| f629cd0ca8 | |||
| 8083b1e5de | |||
| cabd0567f7 | |||
| 31522acd04 | |||
| 4467ef98fd | |||
| 6237ff86d0 | |||
| 616d775303 | |||
| a1a04a89cf | |||
| 279abac91a | |||
| 2bade1825c | |||
| 07478b280c | |||
| 89001f53da | |||
| 18cf0cc140 | |||
| 53a1821547 | |||
| 24cb74ac14 | |||
| 363c8bc121 | |||
| 038c60338b | |||
| f563b41792 | |||
| fd4c0f8963 | |||
| 285c7da87c | |||
| 761810901f | |||
| f507d51c61 | |||
| 240936a850 | |||
| ddbf72ea52 | |||
| 3ef3a16bc0 | |||
| 54cfe4f175 | |||
| ad77b47117 | |||
| 3fe6f980c6 | |||
| dbbad09b1b | |||
| 4222eba8ba | |||
| 79aabd9b62 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ next-env.d.ts
|
|||||||
# PNPM build artifacts
|
# PNPM build artifacts
|
||||||
.pnpm
|
.pnpm
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
|
||||||
|
bin/sync-downloads.mjs
|
||||||
|
|||||||
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
|
||||||
169
AGENTS.md
Normal file
169
AGENTS.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 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, releases, labels, magazines, 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).
|
||||||
|
- `releases/page.tsx` + `ReleasesExplorer.tsx`: Releases search + filters.
|
||||||
|
- `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail.
|
||||||
|
- `genres/`, `languages/`, `machinetypes/`: Category hubs and detail pages.
|
||||||
|
- `magazines/`, `issues/`: Magazine and issue browsing.
|
||||||
|
- `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`). Gracefully handles missing tables (e.g. `releases`) by checking `information_schema.tables`.
|
||||||
|
- 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 <agent-name>@<hostname>
|
||||||
|
- validation and review:
|
||||||
|
- When changes are visual or UX-related, provide concrete links/routes to validate.
|
||||||
|
- Call out what to inspect visually (e.g., section names, table columns, empty states).
|
||||||
|
- Use the local `.env` for any environment-dependent behavior.
|
||||||
|
- Provide fully clickable links when sharing validation URLs.
|
||||||
|
- submodule hygiene:
|
||||||
|
- The `ZXDB` submodule is read-only in this repo; do not commit SQL dumps from it.
|
||||||
|
- Use `bin/setup-zxdb-local.sh` (or `pnpm setup:zxdb-local`) to add local excludes for SQL files.
|
||||||
|
- deploy workflow:
|
||||||
|
- `bin/deploy.sh` refuses to run with uncommitted or untracked files at the repo root.
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- ZXDB setup and API usage: `docs/ZXDB.md`
|
||||||
75
README.md
75
README.md
@@ -1,14 +1,13 @@
|
|||||||
Spectrum Next Explorer
|
Spectrum Next Explorer
|
||||||
|
|
||||||
A Next.js application for exploring the Spectrum Next hardware. It includes a Register Explorer with real‑time search and deep‑linkable queries.
|
A Next.js application for exploring the Spectrum Next ecosystem. It ships with:
|
||||||
|
|
||||||
Features
|
- Register Explorer: parsed from `data/nextreg.txt`, with real‑time search and deep links
|
||||||
- Register Explorer parsed from `data/nextreg.txt`
|
- ZXDB Explorer: a deep, cross‑linked browser for entries, labels, genres, languages, and machine types backed by a MySQL ZXDB instance
|
||||||
- Real‑time filtering with query‑string deep links (e.g. `/registers?q=vram`)
|
|
||||||
- Bootstrap 5 theme with light/dark support
|
- Bootstrap 5 theme with light/dark support
|
||||||
|
|
||||||
Quick start
|
Quick start
|
||||||
- Prerequisites: Node.js 20+, pnpm (recommended)
|
- Prerequisites: Node.js 20+, pnpm (recommended), access to a MySQL server for ZXDB (optional for Registers)
|
||||||
- Install dependencies:
|
- Install dependencies:
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
- Run in development (Turbopack, port 4000):
|
- Run in development (Turbopack, port 4000):
|
||||||
@@ -23,14 +22,70 @@ Project scripts (package.json)
|
|||||||
- `dev`: `PORT=4000 next dev --turbopack`
|
- `dev`: `PORT=4000 next dev --turbopack`
|
||||||
- `build`: `next build --turbopack`
|
- `build`: `next build --turbopack`
|
||||||
- `start`: `next start`
|
- `start`: `next start`
|
||||||
|
- `deploy`: merge current branch into `deploy` and push to `explorer.specnext.dev`
|
||||||
|
- `deploy:branch`: same as `deploy`, but accepts a deploy branch argument
|
||||||
|
- `setup:zxdb-local`: configure local submodule excludes for ZXDB SQL files
|
||||||
- `deploy-test`: push to `test.explorer.specnext.dev`
|
- `deploy-test`: push to `test.explorer.specnext.dev`
|
||||||
- `deploy-prod`: push to `explorer.specnext.dev`
|
- `deploy-prod`: push to `explorer.specnext.dev`
|
||||||
|
|
||||||
Documentation
|
Routes
|
||||||
- Docs index: `docs/index.md`
|
- `/` — Home
|
||||||
- Getting Started: `docs/getting-started.md`
|
- `/registers` — Register Explorer
|
||||||
- Architecture: `docs/architecture.md`
|
- `/zxdb` — ZXDB Explorer (hub)
|
||||||
- Register Explorer: `docs/registers.md`
|
- `/zxdb/entries` — Entries search + filters
|
||||||
|
- `/zxdb/entries/[id]` — Entry detail
|
||||||
|
- `/zxdb/releases` — Releases search + filters
|
||||||
|
- `/zxdb/labels` and `/zxdb/labels/[id]` — Labels search and detail
|
||||||
|
- `/zxdb/genres` and `/zxdb/genres/[id]` — Genres list and entries
|
||||||
|
- `/zxdb/languages` and `/zxdb/languages/[id]` — Languages list and entries
|
||||||
|
- `/zxdb/machinetypes` and `/zxdb/machinetypes/[id]` — Machine types list and entries
|
||||||
|
- `/zxdb/magazines` and `/zxdb/magazines/[id]` — Magazines list and issues
|
||||||
|
- `/zxdb/issues/[id]` — Issue detail (contents and references)
|
||||||
|
|
||||||
|
ZXDB setup (database, env, and helper tables)
|
||||||
|
The Registers section works without any database. The ZXDB Explorer requires a MySQL ZXDB database and one environment variable.
|
||||||
|
|
||||||
|
1) Prepare the database (outside this app)
|
||||||
|
- Import ZXDB data into MySQL. If you want only structure, use `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` in this repo. For data, import ZXDB via your normal process.
|
||||||
|
- Create the helper search tables (required for fast search):
|
||||||
|
- Run `ZXDB/scripts/ZXDB_help_search.sql` against your ZXDB database.
|
||||||
|
- Create a read‑only role/user (recommended):
|
||||||
|
- Example (see `bin/import_mysql.sh`):
|
||||||
|
- Create role `zxdb_readonly`
|
||||||
|
- Grant `SELECT, SHOW VIEW` on database `zxdb`
|
||||||
|
|
||||||
|
2) Configure environment
|
||||||
|
- Copy `.env` from `example.env`.
|
||||||
|
- Set `ZXDB_URL` to a MySQL URL, e.g. `mysql://zxdb_readonly:password@hostname:3306/zxdb`.
|
||||||
|
- On startup, `src/env.ts` validates env vars (t3.gg pattern with Zod) and will fail fast if invalid.
|
||||||
|
|
||||||
|
3) Run the app
|
||||||
|
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`.
|
||||||
|
|
||||||
|
4) Keep the ZXDB submodule clean (recommended)
|
||||||
|
- Run `pnpm setup:zxdb-local` once after cloning.
|
||||||
|
- This keeps `ZXDB/ZXDB_mysql.sql` and `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` available locally without appearing as untracked changes.
|
||||||
|
|
||||||
|
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/releases/search?q=...&year=...&languageId=...&machinetypeId=...&sort=...`
|
||||||
|
- `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`
|
||||||
|
- `GET /api/zxdb/{availabletypes,casetypes,currencies,filetypes,roletypes,schemetypes,sourcetypes}`
|
||||||
|
|
||||||
|
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.
|
||||||
|
- Database Schema: Repository queries include graceful fallback checks (via `information_schema.tables`) to remain functional even if optional tables (like `releases` or `downloads`) are missing from the connected MySQL instance.
|
||||||
|
|
||||||
|
Further reading
|
||||||
|
- ZXDB details and API usage: `docs/ZXDB.md`
|
||||||
|
- Agent/developer workflow and commit guidelines: `AGENTS.md`
|
||||||
|
|
||||||
License
|
License
|
||||||
- See `LICENSE.txt` for details.
|
- See `LICENSE.txt` for details.
|
||||||
|
|||||||
1
ZXDB
Submodule
1
ZXDB
Submodule
Submodule ZXDB added at 3784c91bdd
23
bin/deploy.sh
Executable file
23
bin/deploy.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
deploy_branch="${1:-deploy}"
|
||||||
|
current_branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "Working tree is not clean. Commit or stash changes before deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git ls-files --others --exclude-standard | grep -q .; then
|
||||||
|
echo "Untracked files present. Commit or remove them before deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
git checkout "${current_branch}" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
git checkout "${deploy_branch}"
|
||||||
|
git merge --no-edit "${current_branch}"
|
||||||
|
git push explorer.specnext.dev "${deploy_branch}"
|
||||||
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
|
||||||
18
bin/setup-zxdb-local.sh
Executable file
18
bin/setup-zxdb-local.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git_dir="$(git -C ZXDB rev-parse --git-dir)"
|
||||||
|
exclude_file="${git_dir}/info/exclude"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${exclude_file}")"
|
||||||
|
touch "${exclude_file}"
|
||||||
|
|
||||||
|
add_exclude() {
|
||||||
|
local pattern="$1"
|
||||||
|
if ! grep -Fxq "${pattern}" "${exclude_file}"; then
|
||||||
|
printf "%s\n" "${pattern}" >> "${exclude_file}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_exclude "ZXDB_mysql.sql"
|
||||||
|
add_exclude "ZXDB_mysql_STRUCTURE_ONLY.sql"
|
||||||
119
docs/ZXDB.md
Normal file
119
docs/ZXDB.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 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).
|
||||||
|
- The `ZXDB` submodule is checked in for schemas/scripts; use `pnpm setup:zxdb-local` after cloning to keep local SQL dumps untracked.
|
||||||
|
|
||||||
|
## 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`, `search_by_publishers`, `search_by_aliases`, `search_by_origins`,
|
||||||
|
`search_by_magrefs`, `search_by_magazines`, and `search_by_issues` tables used for search scopes and magazine references.
|
||||||
|
|
||||||
|
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` — Entries search with scope toggles (titles/aliases/origins) and facets.
|
||||||
|
- `/zxdb/entries/[id]` — Entry details with related releases, downloads, origins, relations, and media.
|
||||||
|
- `/zxdb/releases` — Releases search + filters.
|
||||||
|
- `/zxdb/releases/[entryId]/[releaseSeq]` — Release detail: magazine references, downloads, scraps, and issue files.
|
||||||
|
- `/zxdb/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies), permissions, licenses, and authored/published entries.
|
||||||
|
- `/zxdb/genres`, `/zxdb/languages`, `/zxdb/machinetypes` — Category hubs with linked detail pages listing entries.
|
||||||
|
- `/zxdb/magazines` and `/zxdb/magazines/[id]` — Magazine list and issue navigation.
|
||||||
|
- `/zxdb/issues/[id]` — Issue detail with contents and references.
|
||||||
|
|
||||||
|
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`
|
||||||
|
- `scope` — `title`, `title_aliases`, or `title_aliases_origins`
|
||||||
|
- `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.
|
||||||
|
- Missing facets or scope toggles: ensure helper tables from `ZXDB_help_search.sql` exist.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Issue-centric media grouping and richer magazine metadata.
|
||||||
|
- Additional cross-links for tags, relations, and permissions as UI expands.
|
||||||
|
- A11y polish and higher-level navigation enhancements.
|
||||||
@@ -15,6 +15,12 @@ Run in development
|
|||||||
- Command: pnpm dev
|
- Command: pnpm dev
|
||||||
- Then open: http://localhost:4000
|
- Then open: http://localhost:4000
|
||||||
|
|
||||||
|
ZXDB submodule local setup
|
||||||
|
- The ZXDB repo is a submodule used as a read-only reference for schemas/scripts.
|
||||||
|
- Some local SQL files are expected to exist but should stay untracked.
|
||||||
|
- Run: pnpm setup:zxdb-local
|
||||||
|
- This adds local excludes inside the submodule so `git status` stays clean.
|
||||||
|
|
||||||
Build and start (production)
|
Build and start (production)
|
||||||
- Build: pnpm build
|
- Build: pnpm build
|
||||||
- Start: pnpm start
|
- Start: pnpm start
|
||||||
@@ -24,7 +30,9 @@ Lint
|
|||||||
- pnpm lint
|
- pnpm lint
|
||||||
|
|
||||||
Deployment shortcuts
|
Deployment shortcuts
|
||||||
- Two scripts are available in package.json:
|
- Use pnpm deploy (or pnpm deploy:branch) to merge the current branch into `deploy` and push to explorer.specnext.dev.
|
||||||
|
- The deploy script refuses to run if there are uncommitted or untracked files.
|
||||||
|
- One-step push helpers (if you prefer manual branch selection):
|
||||||
- pnpm deploy-test: push the current branch to test.explorer.specnext.dev
|
- pnpm deploy-test: push the current branch to test.explorer.specnext.dev
|
||||||
- pnpm deploy-prod: push the current branch to 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.
|
- Ensure the corresponding Git remotes are configured locally before using these.
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ Welcome to the Spectrum Next Explorer docs. This site provides an overview of th
|
|||||||
- Getting Started: ./getting-started.md
|
- Getting Started: ./getting-started.md
|
||||||
- Architecture: ./architecture.md
|
- Architecture: ./architecture.md
|
||||||
- Register Explorer: ./registers.md
|
- Register Explorer: ./registers.md
|
||||||
|
- ZXDB Explorer: ./ZXDB.md
|
||||||
|
|
||||||
If you’re browsing on GitHub, the main README also links to these documents.
|
If you’re browsing on GitHub, the main README also links to these documents.
|
||||||
|
|||||||
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;
|
||||||
38
example.env
Normal file
38
example.env
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# System hostname for permalinks (mandatory)
|
||||||
|
HOSTNAME=localhost
|
||||||
|
# HTTP varient (mandatory)
|
||||||
|
PROTO=http
|
||||||
|
|
||||||
|
# ZXDB MySQL connection URL (mandatory)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Optional: File prefixes for localized mirroring or rewrite logic
|
||||||
|
# ZXDB_FILE_PREFIX=
|
||||||
|
# WOS_FILE_PREFIX=
|
||||||
|
|
||||||
|
# OIDC Authentication configuration
|
||||||
|
# OIDC_PROVIDER_URL=
|
||||||
|
# OIDC_CLIENT_ID=
|
||||||
|
# OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Redis cache URL (e.g. redis://host:6379)
|
||||||
|
# CACHE_URL=
|
||||||
|
|
||||||
|
# SMTP mail URL (e.g. smtp://user:pass@host:587)
|
||||||
|
# MAIL_URL=
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
sassOptions: {
|
||||||
|
// Silence noisy deprecation warnings coming from dependencies like Bootstrap.
|
||||||
|
quietDeps: true,
|
||||||
|
// Dart Sass deprecations still trigger via @import; explicitly mute them.
|
||||||
|
silenceDeprecations: [
|
||||||
|
"import",
|
||||||
|
"global-builtin",
|
||||||
|
"if-function",
|
||||||
|
"legacy-js-api",
|
||||||
|
"color-functions",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -1,31 +1,40 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"deploy": "bin/deploy.sh",
|
||||||
|
"deploy:branch": "bin/deploy.sh",
|
||||||
|
"setup:zxdb-local": "bin/setup-zxdb-local.sh",
|
||||||
"deploy-prod": "git push --set-upstream explorer.specnext.dev deploy",
|
"deploy-prod": "git push --set-upstream explorer.specnext.dev deploy",
|
||||||
"deploy-test": "git push --set-upstream test.explorer.specnext.dev test"
|
"deploy-test": "git push --set-upstream test.explorer.specnext.dev test"
|
||||||
},
|
},
|
||||||
"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";
|
||||||
50
src/app/api/zxdb/search/route.ts
Normal file
50
src/app/api/zxdb/search/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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(),
|
||||||
|
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).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,
|
||||||
|
scope: searchParams.get("scope") ?? 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 },
|
||||||
};
|
};
|
||||||
|
|||||||
203
src/app/registers/[hex]/opengraph-image.tsx
Normal file
203
src/app/registers/[hex]/opengraph-image.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { getRegisters } from '@/services/register.service';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export const size = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
|
const buildRegisterSummaryLines = (register: { description: string; text: string; modes: { text: string }[] }) => {
|
||||||
|
const isInfoLine = (line: string) =>
|
||||||
|
line.length > 0 &&
|
||||||
|
!line.startsWith('//') &&
|
||||||
|
!line.startsWith('(R') &&
|
||||||
|
!line.startsWith('(W') &&
|
||||||
|
!line.startsWith('(R/W') &&
|
||||||
|
!line.startsWith('*') &&
|
||||||
|
!/^bits?\s+\d/i.test(line);
|
||||||
|
|
||||||
|
const normalizeLines = (raw: string) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const rawLines = raw.split('\n');
|
||||||
|
for (const rawLine of rawLines) {
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isInfoLine(trimmed)) {
|
||||||
|
lines.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const textLines = normalizeLines(register.text);
|
||||||
|
const modeLines = register.modes.flatMap(mode => normalizeLines(mode.text));
|
||||||
|
const descriptionLines = normalizeLines(register.description);
|
||||||
|
|
||||||
|
const combined: string[] = [];
|
||||||
|
const appendBlock = (block: string[]) => {
|
||||||
|
if (block.length === 0) return;
|
||||||
|
if (combined.length > 0 && combined[combined.length - 1] !== '') {
|
||||||
|
combined.push('');
|
||||||
|
}
|
||||||
|
combined.push(...block);
|
||||||
|
};
|
||||||
|
|
||||||
|
appendBlock(textLines);
|
||||||
|
appendBlock(modeLines);
|
||||||
|
appendBlock(descriptionLines);
|
||||||
|
|
||||||
|
const deduped: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const line of combined) {
|
||||||
|
if (!line) {
|
||||||
|
if (deduped.length > 0 && deduped[deduped.length - 1] !== '') {
|
||||||
|
deduped.push('');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(line);
|
||||||
|
deduped.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped.length > 0 ? deduped : ['Spectrum Next register details and bit-level behavior.'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitLongWord = (word: string, maxLineLength: number) => {
|
||||||
|
if (word.length <= maxLineLength) return [word];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let idx = 0; idx < word.length; idx += maxLineLength - 1) {
|
||||||
|
chunks.push(`${word.slice(idx, idx + maxLineLength - 1)}-`);
|
||||||
|
}
|
||||||
|
const last = chunks[chunks.length - 1];
|
||||||
|
chunks[chunks.length - 1] = last.endsWith('-') ? last.slice(0, -1) : last;
|
||||||
|
return chunks;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapText = (text: string, maxLineLength: number) => {
|
||||||
|
const words = text
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap(word => splitLongWord(word, maxLineLength));
|
||||||
|
const lines: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const next = current ? `${current} ${word}` : word;
|
||||||
|
if (next.length > maxLineLength && current) {
|
||||||
|
lines.push(current);
|
||||||
|
current = word;
|
||||||
|
} else {
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
lines.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapTextLines = (sourceLines: string[], maxLineLength: number, maxLines: number) => {
|
||||||
|
const output: string[] = [];
|
||||||
|
for (const line of sourceLines) {
|
||||||
|
if (output.length >= maxLines) break;
|
||||||
|
if (!line) {
|
||||||
|
if (output.length > 0 && output[output.length - 1] !== '') {
|
||||||
|
output.push('');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const wrapped = wrapText(line, maxLineLength);
|
||||||
|
for (const wrappedLine of wrapped) {
|
||||||
|
if (output.length >= maxLines) break;
|
||||||
|
output.push(wrappedLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output.slice(0, maxLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Image({ params }: { params: Promise<{ hex: string }> }) {
|
||||||
|
const { hex } = await params;
|
||||||
|
const targetHex = decodeURIComponent(hex).toLowerCase();
|
||||||
|
const registers = await getRegisters();
|
||||||
|
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);
|
||||||
|
|
||||||
|
const title = register ? `${register.hex_address} ${register.name}` : 'Spectrum Next Register';
|
||||||
|
const summaryLinesSource = register
|
||||||
|
? buildRegisterSummaryLines(register)
|
||||||
|
: ['Register details not found.'];
|
||||||
|
const decAddress = register ? `Dec ${register.dec_address}` : '';
|
||||||
|
const titleLines = wrapTextLines([title], 32, 2);
|
||||||
|
const summaryLines = wrapTextLines(summaryLinesSource, 54, 6);
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '1200px',
|
||||||
|
height: '630px',
|
||||||
|
display: 'flex',
|
||||||
|
background: 'linear-gradient(135deg, #3f1f6e 0%, #593196 55%, #7a4cc4 100%)',
|
||||||
|
color: '#f3f2ed',
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
padding: '64px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', width: '100%' }}>
|
||||||
|
<div style={{ fontSize: '28px', letterSpacing: '2px', textTransform: 'uppercase', color: '#e8dcff' }}>
|
||||||
|
Spectrum Next Explorer
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '68px',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.05,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titleLines.map(line => (
|
||||||
|
<div key={line}>{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '32px',
|
||||||
|
lineHeight: 1.35,
|
||||||
|
color: '#f7f1ff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{summaryLines.map(line => (
|
||||||
|
<div key={line}>{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 'auto', fontSize: '26px', color: '#dacbff' }}>
|
||||||
|
{decAddress}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,79 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
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';
|
||||||
|
import {env} from "@/env";
|
||||||
|
|
||||||
export default async function RegisterDetailPage({ params }: { params: { hex: string } }) {
|
const buildRegisterSummary = (register: { description: string; text: string; modes: { text: string }[] }) => {
|
||||||
|
const trimLine = (line: string) => line.trim();
|
||||||
|
const isInfoLine = (line: string) =>
|
||||||
|
line.length > 0 &&
|
||||||
|
!line.startsWith('//') &&
|
||||||
|
!line.startsWith('(R') &&
|
||||||
|
!line.startsWith('(W') &&
|
||||||
|
!line.startsWith('(R/W') &&
|
||||||
|
!line.startsWith('*') &&
|
||||||
|
!/^bits?\s+\d/i.test(line);
|
||||||
|
|
||||||
|
const modeLines = register.modes.flatMap(mode => mode.text.split('\n')).map(trimLine).filter(isInfoLine);
|
||||||
|
const textLines = register.text.split('\n').map(trimLine).filter(isInfoLine);
|
||||||
|
const descriptionLines = register.description.split('\n').map(trimLine).filter(isInfoLine);
|
||||||
|
|
||||||
|
const rawSummary = [...textLines, ...modeLines, ...descriptionLines].join(' ').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!rawSummary) return 'Spectrum Next register details and bit-level behavior.';
|
||||||
|
if (rawSummary.length <= 180) return rawSummary;
|
||||||
|
return `${rawSummary.slice(0, 177).trimEnd()}...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ hex: string }> }): Promise<Metadata> {
|
||||||
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);
|
||||||
|
|
||||||
|
if (!register) {
|
||||||
|
return {
|
||||||
|
title: 'Register Not Found | Spectrum Next Explorer',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = buildRegisterSummary(register);
|
||||||
|
const title = `${register.hex_address} ${register.name} | Spectrum Next Explorer`;
|
||||||
|
const imageUrl = `${env.PROTO}://${env.HOSTNAME}/registers/${register.hex_address}/opengraph-image`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: summary,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description: summary,
|
||||||
|
type: 'article',
|
||||||
|
url: `${env.PROTO}://${env.HOSTNAME}/registers/${register.hex_address}`,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: imageUrl,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: `${register.hex_address} ${register.name}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title,
|
||||||
|
description: summary,
|
||||||
|
images: [imageUrl],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RegisterDetailPage({ params }: { params: Promise<{ hex: string }> }) {
|
||||||
|
const registers = await getRegisters();
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/zxdb/components/ZxdbBreadcrumbs.tsx
Normal file
27
src/app/zxdb/components/ZxdbBreadcrumbs.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Crumb = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ZxdbBreadcrumbs({ items }: { items: Crumb[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const lastIndex = items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol className="breadcrumb">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isActive = index === lastIndex || !item.href;
|
||||||
|
return (
|
||||||
|
<li key={`${item.label}-${index}`} className={`breadcrumb-item${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
|
||||||
|
{isActive ? item.label : <Link href={item.href ?? "#"}>{item.label}</Link>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
455
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
455
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import EntryLink from "../components/EntryLink";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
isXrated: number;
|
||||||
|
genreId: number | null;
|
||||||
|
genreName?: string | null;
|
||||||
|
machinetypeId: number | null;
|
||||||
|
machinetypeName?: string | null;
|
||||||
|
languageId: string | null;
|
||||||
|
languageName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
|
||||||
|
|
||||||
|
type Paged<T> = {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntryFacets = {
|
||||||
|
genres: { id: number; name: string; count: number }[];
|
||||||
|
languages: { id: string; name: string; count: number }[];
|
||||||
|
machinetypes: { id: number; name: string; count: number }[];
|
||||||
|
flags: { hasAliases: number; hasOrigins: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntriesExplorer({
|
||||||
|
initial,
|
||||||
|
initialGenres,
|
||||||
|
initialLanguages,
|
||||||
|
initialMachines,
|
||||||
|
initialFacets,
|
||||||
|
initialUrlState,
|
||||||
|
}: {
|
||||||
|
initial?: Paged<Item>;
|
||||||
|
initialGenres?: { id: number; name: string }[];
|
||||||
|
initialLanguages?: { id: string; name: string }[];
|
||||||
|
initialMachines?: { id: number; name: string }[];
|
||||||
|
initialFacets?: EntryFacets | null;
|
||||||
|
initialUrlState?: {
|
||||||
|
q: string;
|
||||||
|
page: number;
|
||||||
|
genreId: string | number | "";
|
||||||
|
languageId: string | "";
|
||||||
|
machinetypeId: string | number | "";
|
||||||
|
sort: "title" | "id_desc";
|
||||||
|
scope?: SearchScope;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
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 [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
|
||||||
|
const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null);
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
const activeFilters = useMemo(() => {
|
||||||
|
const chips: string[] = [];
|
||||||
|
if (q) chips.push(`q: ${q}`);
|
||||||
|
if (genreId !== "") {
|
||||||
|
const name = genres.find((g) => g.id === Number(genreId))?.name ?? `#${genreId}`;
|
||||||
|
chips.push(`genre: ${name}`);
|
||||||
|
}
|
||||||
|
if (languageId !== "") {
|
||||||
|
const name = languages.find((l) => l.id === languageId)?.name ?? languageId;
|
||||||
|
chips.push(`lang: ${name}`);
|
||||||
|
}
|
||||||
|
if (machinetypeId !== "") {
|
||||||
|
const name = machines.find((m) => m.id === Number(machinetypeId))?.name ?? `#${machinetypeId}`;
|
||||||
|
chips.push(`machine: ${name}`);
|
||||||
|
}
|
||||||
|
if (scope === "title_aliases") chips.push("scope: titles + aliases");
|
||||||
|
if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins");
|
||||||
|
return chips;
|
||||||
|
}, [q, genreId, languageId, machinetypeId, scope, genres, languages, machines]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
|
const qs = params.toString();
|
||||||
|
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData(query: string, p: number, withFacets: boolean) {
|
||||||
|
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);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
|
if (withFacets) params.set("facets", "true");
|
||||||
|
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
if (withFacets && json.facets) {
|
||||||
|
setFacets(json.facets as EntryFacets);
|
||||||
|
}
|
||||||
|
} 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") &&
|
||||||
|
(initialUrlState?.scope ?? "title") === scope
|
||||||
|
) {
|
||||||
|
updateUrl(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateUrl(page);
|
||||||
|
fetchData(q, page, true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, genreId, languageId, machinetypeId, sort, scope]);
|
||||||
|
|
||||||
|
// 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, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
setQ("");
|
||||||
|
setGenreId("");
|
||||||
|
setLanguageId("");
|
||||||
|
setMachinetypeId("");
|
||||||
|
setSort("id_desc");
|
||||||
|
setScope("title");
|
||||||
|
setPage(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);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
|
return `/zxdb/entries?${params.toString()}`;
|
||||||
|
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
|
return `/zxdb/entries?${params.toString()}`;
|
||||||
|
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Entries" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Entries</h1>
|
||||||
|
<div className="text-secondary">
|
||||||
|
{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeFilters.length > 0 && (
|
||||||
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
{activeFilters.map((chip) => (
|
||||||
|
<span key={chip} className="badge text-bg-light">{chip}</span>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={resetFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Search titles..."
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-grid">
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Genre</label>
|
||||||
|
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||||
|
<option value="">All genres</option>
|
||||||
|
{genres.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Language</label>
|
||||||
|
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">All languages</option>
|
||||||
|
{languages.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Machine</label>
|
||||||
|
<select className="form-select" value={machinetypeId} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||||
|
<option value="">All machines</option>
|
||||||
|
{machines.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Sort</label>
|
||||||
|
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
|
||||||
|
<option value="title">Title (A–Z)</option>
|
||||||
|
<option value="id_desc">Newest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search scope</label>
|
||||||
|
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
|
||||||
|
<option value="title">Titles</option>
|
||||||
|
<option value="title_aliases">Titles + Aliases</option>
|
||||||
|
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{facets && (
|
||||||
|
<div>
|
||||||
|
<div className="text-secondary small mb-1">Facets</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${scope === "title_aliases" ? "btn-primary" : "btn-outline-secondary"}`}
|
||||||
|
onClick={() => { setScope("title_aliases"); setPage(1); }}
|
||||||
|
disabled={facets.flags.hasAliases === 0}
|
||||||
|
title="Show results that match aliases"
|
||||||
|
>
|
||||||
|
Has aliases ({facets.flags.hasAliases})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${scope === "title_aliases_origins" ? "btn-primary" : "btn-outline-secondary"}`}
|
||||||
|
onClick={() => { setScope("title_aliases_origins"); setPage(1); }}
|
||||||
|
disabled={facets.flags.hasOrigins === 0}
|
||||||
|
title="Show results that match origins"
|
||||||
|
>
|
||||||
|
Has origins ({facets.flags.hasOrigins})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && <div className="text-secondary small">Loading...</div>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
{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 }}>Genre</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.genreId != null ? (
|
||||||
|
it.genreName ? (
|
||||||
|
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{it.genreId}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-4">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
816
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
816
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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[];
|
||||||
|
licenses?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
isOfficial: boolean;
|
||||||
|
linkWikipedia?: string | null;
|
||||||
|
linkSite?: string | null;
|
||||||
|
comments?: string | null;
|
||||||
|
}[];
|
||||||
|
relations?: {
|
||||||
|
direction: "from" | "to";
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
entry: { id: number; title: string | null };
|
||||||
|
}[];
|
||||||
|
tags?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
category: { id: number | null; name: string | null };
|
||||||
|
memberSeq: number | null;
|
||||||
|
link: string | null;
|
||||||
|
comments: string | null;
|
||||||
|
}[];
|
||||||
|
ports?: {
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
platform: { id: number; name: string | null };
|
||||||
|
isOfficial: boolean;
|
||||||
|
linkSystem: string | null;
|
||||||
|
}[];
|
||||||
|
remakes?: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
fileLink: string;
|
||||||
|
fileDate: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
authors: string | null;
|
||||||
|
platforms: string | null;
|
||||||
|
remakeYears: string | null;
|
||||||
|
remakeStatus: string | null;
|
||||||
|
}[];
|
||||||
|
scores?: {
|
||||||
|
website: { id: number; name: string | null };
|
||||||
|
score: number;
|
||||||
|
votes: number;
|
||||||
|
}[];
|
||||||
|
notes?: {
|
||||||
|
id: number;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
text: string;
|
||||||
|
}[];
|
||||||
|
origins?: {
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
libraryTitle: string;
|
||||||
|
publication: string | null;
|
||||||
|
containerId: number | null;
|
||||||
|
issueId: number | null;
|
||||||
|
issue: { id: number; magazineId: number | null; magazineTitle: string | null } | null;
|
||||||
|
date: { year: number | null; month: number | null; day: number | null };
|
||||||
|
}[];
|
||||||
|
// 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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Entries", href: "/zxdb/entries" },
|
||||||
|
{ label: data.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="row g-3 mt-2">
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Entry Summary</h5>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 180 }}>ID</th>
|
||||||
|
<td>{data.id}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<td>{data.title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Machine</th>
|
||||||
|
<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>
|
||||||
|
<th>Language</th>
|
||||||
|
<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>
|
||||||
|
<th>Genre</th>
|
||||||
|
<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>
|
||||||
|
<th>Max Players</th>
|
||||||
|
<td>{data.maxPlayers}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.availabletypeId !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<th>Available Type</th>
|
||||||
|
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.withoutLoadScreen !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<th>Without Load Screen</th>
|
||||||
|
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.withoutInlay !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<th>Without Inlay</th>
|
||||||
|
<td>{data.withoutInlay ? "Yes" : "No"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.issueId !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<th>Issue</th>
|
||||||
|
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">People</h5>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="text-secondary small mb-1">Authors</div>
|
||||||
|
{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-12">
|
||||||
|
<div className="text-secondary small mb-1">Publishers</div>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body d-flex flex-wrap 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">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}
|
||||||
|
<Link className="badge text-bg-light text-decoration-none" href={`/zxdb/releases/${data.id}/${d.releaseSeq}`}>
|
||||||
|
rel #{d.releaseSeq}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{d.comments ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Releases</h5>
|
||||||
|
{(!data.releases || data.releases.length === 0) && <div className="text-secondary">No releases recorded</div>}
|
||||||
|
{data.releases && data.releases.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 120 }}>Release #</th>
|
||||||
|
<th style={{ width: 120 }}>Year</th>
|
||||||
|
<th>Downloads</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.releases.map((r) => (
|
||||||
|
<tr key={r.releaseSeq}>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/releases/${data.id}/${r.releaseSeq}`}>#{r.releaseSeq}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{r.year ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>{r.downloads.length}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Origins</h5>
|
||||||
|
{(!data.origins || data.origins.length === 0) && <div className="text-secondary">No origins recorded</div>}
|
||||||
|
{data.origins && data.origins.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Publication</th>
|
||||||
|
<th style={{ width: 200 }}>Issue</th>
|
||||||
|
<th style={{ width: 140 }}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.origins.map((o, idx) => {
|
||||||
|
const dateParts = [o.date.year, o.date.month, o.date.day]
|
||||||
|
.filter((v) => typeof v === "number" && Number.isFinite(v))
|
||||||
|
.map((v, i) => (i === 0 ? String(v) : String(v).padStart(2, "0")));
|
||||||
|
const dateText = dateParts.length ? dateParts.join("/") : "-";
|
||||||
|
return (
|
||||||
|
<tr key={`${o.type.id}-${idx}`}>
|
||||||
|
<td>{o.type.name ?? o.type.id}</td>
|
||||||
|
<td>{o.libraryTitle}</td>
|
||||||
|
<td>{o.publication ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>
|
||||||
|
{o.issue ? (
|
||||||
|
<div className="d-flex flex-column">
|
||||||
|
<Link href={`/zxdb/issues/${o.issue.id}`}>Issue #{o.issue.id}</Link>
|
||||||
|
{o.issue.magazineId != null && (
|
||||||
|
<Link className="text-secondary small" href={`/zxdb/magazines/${o.issue.magazineId}`}>
|
||||||
|
{o.issue.magazineTitle ?? `Magazine #${o.issue.magazineId}`}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : o.containerId ? (
|
||||||
|
<span>Container #{o.containerId}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{dateText}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Relations</h5>
|
||||||
|
{(!data.relations || data.relations.length === 0) && <div className="text-secondary">No relations recorded</div>}
|
||||||
|
{data.relations && data.relations.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 90 }}>Direction</th>
|
||||||
|
<th style={{ width: 160 }}>Type</th>
|
||||||
|
<th>Entry</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.relations.map((r, idx) => (
|
||||||
|
<tr key={`${r.entry.id}-${r.type.id}-${idx}`}>
|
||||||
|
<td>{r.direction === "from" ? "From" : "To"}</td>
|
||||||
|
<td>{r.type.name ?? r.type.id}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/entries/${r.entry.id}`}>
|
||||||
|
{r.entry.title ?? `Entry #${r.entry.id}`}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Tags / Members</h5>
|
||||||
|
{(!data.tags || data.tags.length === 0) && <div className="text-secondary">No tags recorded</div>}
|
||||||
|
{data.tags && data.tags.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th style={{ width: 140 }}>Category</th>
|
||||||
|
<th style={{ width: 120 }}>Member Seq</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.tags.map((t) => (
|
||||||
|
<tr key={`${t.id}-${t.category.id ?? "none"}`}>
|
||||||
|
<td>{t.name}</td>
|
||||||
|
<td>{t.type.name ?? t.type.id}</td>
|
||||||
|
<td>{t.category.name ?? (t.category.id != null ? `#${t.category.id}` : "-")}</td>
|
||||||
|
<td>{t.memberSeq ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
{t.link && (
|
||||||
|
<a href={t.link} target="_blank" rel="noreferrer">Link</a>
|
||||||
|
)}
|
||||||
|
{t.comments && <span className="text-secondary">{t.comments}</span>}
|
||||||
|
{!t.link && !t.comments && <span className="text-secondary">-</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Ports</h5>
|
||||||
|
{(!data.ports || data.ports.length === 0) && <div className="text-secondary">No ports recorded</div>}
|
||||||
|
{data.ports && data.ports.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Platform</th>
|
||||||
|
<th style={{ width: 120 }}>Official</th>
|
||||||
|
<th>Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.ports.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.title ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>{p.platform.name ?? `#${p.platform.id}`}</td>
|
||||||
|
<td>{p.isOfficial ? "Yes" : "No"}</td>
|
||||||
|
<td>
|
||||||
|
{p.linkSystem ? (
|
||||||
|
<a href={p.linkSystem} target="_blank" rel="noreferrer">Link</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Remakes</h5>
|
||||||
|
{(!data.remakes || data.remakes.length === 0) && <div className="text-secondary">No remakes recorded</div>}
|
||||||
|
{data.remakes && data.remakes.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Platforms</th>
|
||||||
|
<th style={{ width: 140 }}>Years</th>
|
||||||
|
<th style={{ width: 140 }}>File</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.remakes.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{r.title}</td>
|
||||||
|
<td>{r.platforms ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>{r.remakeYears ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>
|
||||||
|
{r.fileLink ? (
|
||||||
|
<a href={r.fileLink} target="_blank" rel="noreferrer">File</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{r.remakeStatus ?? r.authors ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Scores</h5>
|
||||||
|
{(!data.scores || data.scores.length === 0) && <div className="text-secondary">No scores recorded</div>}
|
||||||
|
{data.scores && data.scores.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th style={{ width: 120 }}>Score</th>
|
||||||
|
<th style={{ width: 120 }}>Votes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.scores.map((s, idx) => (
|
||||||
|
<tr key={`${s.website.id}-${idx}`}>
|
||||||
|
<td>{s.website.name ?? `#${s.website.id}`}</td>
|
||||||
|
<td>{s.score}</td>
|
||||||
|
<td>{s.votes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Notes</h5>
|
||||||
|
{(!data.notes || data.notes.length === 0) && <div className="text-secondary">No notes recorded</div>}
|
||||||
|
{data.notes && data.notes.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th>Text</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.notes.map((n) => (
|
||||||
|
<tr key={n.id}>
|
||||||
|
<td>{n.type.name ?? n.type.id}</td>
|
||||||
|
<td>{n.text}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">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>
|
||||||
|
<Link href={`/zxdb/releases/${data.id}/${a.releaseSeq}`}>#{a.releaseSeq}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{a.languageId}</td>
|
||||||
|
<td>{a.title}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Licenses</h5>
|
||||||
|
{(!data.licenses || data.licenses.length === 0) && <div className="text-secondary">No licenses linked</div>}
|
||||||
|
{data.licenses && data.licenses.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th style={{ width: 120 }}>Official</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.licenses.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.name}</td>
|
||||||
|
<td>{l.type.name ?? l.type.id}</td>
|
||||||
|
<td>{l.isOfficial ? "Yes" : "No"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
{l.linkWikipedia && (
|
||||||
|
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
|
||||||
|
)}
|
||||||
|
{l.linkSite && (
|
||||||
|
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
|
||||||
|
)}
|
||||||
|
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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} />;
|
||||||
|
}
|
||||||
57
src/app/zxdb/entries/page.tsx
Normal file
57
src/app/zxdb/entries/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import EntriesExplorer from "./EntriesExplorer";
|
||||||
|
import { getEntryFacets, 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 scope = ((Array.isArray(sp.scope) ? sp.scope[0] : sp.scope) ?? "title") as
|
||||||
|
| "title"
|
||||||
|
| "title_aliases"
|
||||||
|
| "title_aliases_origins";
|
||||||
|
|
||||||
|
const [initial, genres, langs, machines, facets] = await Promise.all([
|
||||||
|
searchEntries({
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
sort,
|
||||||
|
q,
|
||||||
|
scope,
|
||||||
|
genreId: genreId ? Number(genreId) : undefined,
|
||||||
|
languageId: languageId || undefined,
|
||||||
|
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||||
|
}),
|
||||||
|
listGenres(),
|
||||||
|
listLanguages(),
|
||||||
|
listMachinetypes(),
|
||||||
|
getEntryFacets({
|
||||||
|
q,
|
||||||
|
sort,
|
||||||
|
scope,
|
||||||
|
genreId: genreId ? Number(genreId) : undefined,
|
||||||
|
languageId: languageId || undefined,
|
||||||
|
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntriesExplorer
|
||||||
|
initial={initial}
|
||||||
|
initialGenres={genres}
|
||||||
|
initialLanguages={langs}
|
||||||
|
initialMachines={machines}
|
||||||
|
initialFacets={facets}
|
||||||
|
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/app/zxdb/genres/GenresSearch.tsx
Normal file
114
src/app/zxdb/genres/GenresSearch.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Genres" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Genres</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
|
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="d-grid">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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} />;
|
||||||
|
}
|
||||||
89
src/app/zxdb/issues/[id]/page.tsx
Normal file
89
src/app/zxdb/issues/[id]/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getIssue } from "@/server/repo/zxdb";
|
||||||
|
import EntryLink from "@/app/zxdb/components/EntryLink";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Magazines", href: "/zxdb/magazines" },
|
||||||
|
{ label: issue.magazine.title, href: `/zxdb/magazines/${issue.magazine.id}` },
|
||||||
|
{ label: `Issue ${ym || issue.id}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/app/zxdb/labels/LabelsSearch.tsx
Normal file
120
src/app/zxdb/labels/LabelsSearch.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Labels" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Labels</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
|
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="d-grid">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
219
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"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;
|
||||||
|
labeltypeName: string | null;
|
||||||
|
permissions: {
|
||||||
|
website: { id: number; name: string; link?: string | null };
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
text: string | null;
|
||||||
|
}[];
|
||||||
|
licenses: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
linkWikipedia?: string | null;
|
||||||
|
linkSite?: string | null;
|
||||||
|
comments?: 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.labeltypeName
|
||||||
|
? `${initial.label.labeltypeName} (${initial.label.labeltypeId ?? "?"})`
|
||||||
|
: (initial.label.labeltypeId ?? "?")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-4 mt-1">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<h5>Permissions</h5>
|
||||||
|
{initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>}
|
||||||
|
{initial.label.permissions.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initial.label.permissions.map((p, idx) => (
|
||||||
|
<tr key={`${p.website.id}-${p.type.id}-${idx}`}>
|
||||||
|
<td>
|
||||||
|
{p.website.link ? (
|
||||||
|
<a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
|
||||||
|
) : (
|
||||||
|
<span>{p.website.name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{p.type.name ?? p.type.id}</td>
|
||||||
|
<td>{p.text ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<h5>Licenses</h5>
|
||||||
|
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
|
||||||
|
{initial.label.licenses.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initial.label.licenses.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.name}</td>
|
||||||
|
<td>{l.type.name ?? l.type.id}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
{l.linkWikipedia && (
|
||||||
|
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
|
||||||
|
)}
|
||||||
|
{l.linkSite && (
|
||||||
|
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
|
||||||
|
)}
|
||||||
|
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/app/zxdb/languages/LanguagesSearch.tsx
Normal file
114
src/app/zxdb/languages/LanguagesSearch.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Languages" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Languages</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
|
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="d-grid">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal file
116
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Machine Types" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Machine Types</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
|
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="d-grid">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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} />;
|
||||||
|
}
|
||||||
94
src/app/zxdb/magazines/[id]/page.tsx
Normal file
94
src/app/zxdb/magazines/[id]/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getMagazine } from "@/server/repo/zxdb";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Magazines", href: "/zxdb/magazines" },
|
||||||
|
{ label: mag.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/zxdb/magazines/page.tsx
Normal file
117
src/app/zxdb/magazines/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { listMagazines } from "@/server/repo/zxdb";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Magazines" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Magazines</h1>
|
||||||
|
<div className="text-secondary">{data.total.toLocaleString()} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" action="/zxdb/magazines" method="get">
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
|
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
|
||||||
|
</div>
|
||||||
|
<div className="d-grid">
|
||||||
|
<button className="btn btn-primary" type="submit">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 140 }}>Language</th>
|
||||||
|
<th style={{ width: 120 }}>Issues</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/magazines/${m.id}`}>{m.title}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{m.languageId}</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-secondary rounded-pill" title="Issues">
|
||||||
|
{m.issueCount}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/app/zxdb/page.tsx
Normal file
92
src/app/zxdb/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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>
|
||||||
|
|
||||||
|
<form className="row gy-2 gx-2 align-items-center mb-4" method="get" action="/zxdb/entries">
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" name="q" placeholder="Search entries..." />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" name="scope" defaultValue="title">
|
||||||
|
<option value="title">Titles</option>
|
||||||
|
<option value="title_aliases">Titles + Aliases</option>
|
||||||
|
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
433
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
433
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import EntryLink from "../components/EntryLink";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
entryId: number;
|
||||||
|
releaseSeq: number;
|
||||||
|
entryTitle: string;
|
||||||
|
year: number | null;
|
||||||
|
magrefCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Paged<T> = {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReleasesExplorer({
|
||||||
|
initial,
|
||||||
|
initialUrlState,
|
||||||
|
initialUrlHasParams,
|
||||||
|
initialLists,
|
||||||
|
}: {
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
initialUrlHasParams?: boolean;
|
||||||
|
initialLists?: {
|
||||||
|
languages: { id: string; name: string }[];
|
||||||
|
machinetypes: { id: number; name: string }[];
|
||||||
|
filetypes: { id: number; name: string }[];
|
||||||
|
schemetypes: { id: string; name: string }[];
|
||||||
|
sourcetypes: { id: string; name: string }[];
|
||||||
|
casetypes: { id: string; name: string }[];
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
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 }[]>(initialLists?.languages ?? []);
|
||||||
|
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []);
|
||||||
|
const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []);
|
||||||
|
const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []);
|
||||||
|
const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []);
|
||||||
|
const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []);
|
||||||
|
const initialLoad = useRef(true);
|
||||||
|
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
if (initialLoad.current) {
|
||||||
|
initialLoad.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateUrl(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialLoad.current) {
|
||||||
|
initialLoad.current = false;
|
||||||
|
if (initial && !initialUrlHasParams) 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() {
|
||||||
|
if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return;
|
||||||
|
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>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Releases" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Releases</h1>
|
||||||
|
<div className="text-secondary">
|
||||||
|
{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Filter by entry title..."
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-grid">
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Year</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Any"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">DL Language</label>
|
||||||
|
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">All languages</option>
|
||||||
|
{langs.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">DL Machine</label>
|
||||||
|
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">All machines</option>
|
||||||
|
{machines.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">File type</label>
|
||||||
|
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">All file types</option>
|
||||||
|
{filetypes.map((ft) => (
|
||||||
|
<option key={ft.id} value={ft.id}>{ft.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Scheme</label>
|
||||||
|
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">All schemes</option>
|
||||||
|
{schemes.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Source</label>
|
||||||
|
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">All sources</option>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Case</label>
|
||||||
|
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">All cases</option>
|
||||||
|
{cases.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<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>
|
||||||
|
<label className="form-label small text-secondary">Sort</label>
|
||||||
|
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
|
||||||
|
<option value="year_desc">Newest</option>
|
||||||
|
<option value="year_asc">Oldest</option>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="entry_id_desc">Entry ID</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{loading && <div className="text-secondary small">Loading...</div>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
{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: 140 }}>Release #</th>
|
||||||
|
<th style={{ width: 110 }}>Places</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>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`} className="link-underline link-underline-opacity-0">
|
||||||
|
{it.entryTitle || `Entry #${it.entryId}`}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
|
||||||
|
#{it.releaseSeq}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{it.magrefCount > 0 ? (
|
||||||
|
<span className="badge text-bg-secondary">{it.magrefCount}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{it.year ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-4">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
532
src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx
Normal file
532
src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
|
type ReleaseDetailData = {
|
||||||
|
entry: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
issueId: number | null;
|
||||||
|
};
|
||||||
|
entryReleases: Array<{
|
||||||
|
releaseSeq: number;
|
||||||
|
year: number | null;
|
||||||
|
}>;
|
||||||
|
release: {
|
||||||
|
entryId: number;
|
||||||
|
releaseSeq: number;
|
||||||
|
year: number | null;
|
||||||
|
month: number | null;
|
||||||
|
day: number | null;
|
||||||
|
currency: { id: string | null; name: string | null; symbol: string | null; prefix: number | null };
|
||||||
|
prices: {
|
||||||
|
release: number | null;
|
||||||
|
budget: number | null;
|
||||||
|
microdrive: number | null;
|
||||||
|
disk: number | null;
|
||||||
|
cartridge: number | null;
|
||||||
|
};
|
||||||
|
book: { isbn: string | null; pages: number | null };
|
||||||
|
};
|
||||||
|
downloads: Array<{
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
scraps: Array<{
|
||||||
|
id: number;
|
||||||
|
link: string | null;
|
||||||
|
size: number | null;
|
||||||
|
comments: string | null;
|
||||||
|
rationale: string;
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
files: Array<{
|
||||||
|
id: number;
|
||||||
|
link: string;
|
||||||
|
size: number | null;
|
||||||
|
md5: string | null;
|
||||||
|
comments: string | null;
|
||||||
|
type: { id: number; name: string };
|
||||||
|
}>;
|
||||||
|
magazineRefs: Array<{
|
||||||
|
id: number;
|
||||||
|
issueId: number;
|
||||||
|
magazineId: number | null;
|
||||||
|
magazineName: string | null;
|
||||||
|
referencetypeId: number;
|
||||||
|
referencetypeName: string | null;
|
||||||
|
page: number;
|
||||||
|
isOriginal: number;
|
||||||
|
scoreGroup: string;
|
||||||
|
issue: {
|
||||||
|
dateYear: number | null;
|
||||||
|
dateMonth: number | null;
|
||||||
|
dateDay: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
number: number | null;
|
||||||
|
special: string | null;
|
||||||
|
supplement: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatIssue(issue: ReleaseDetailData["magazineRefs"][number]["issue"]) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (issue.volume != null) parts.push(`v.${issue.volume}`);
|
||||||
|
if (issue.number != null) parts.push(`#${issue.number}`);
|
||||||
|
if (issue.dateYear != null) {
|
||||||
|
let date = `${issue.dateYear}`;
|
||||||
|
if (issue.dateMonth != null) {
|
||||||
|
const mm = String(issue.dateMonth).padStart(2, "0");
|
||||||
|
date += `/${mm}`;
|
||||||
|
if (issue.dateDay != null) {
|
||||||
|
const dd = String(issue.dateDay).padStart(2, "0");
|
||||||
|
date += `/${dd}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(date);
|
||||||
|
}
|
||||||
|
if (issue.special) parts.push(`special "${issue.special}"`);
|
||||||
|
if (issue.supplement) parts.push(`supplement "${issue.supplement}"`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number | null, currency: ReleaseDetailData["release"]["currency"]) {
|
||||||
|
if (value == null) return "-";
|
||||||
|
if (currency.symbol) {
|
||||||
|
return currency.prefix ? `${currency.symbol}${value}` : `${value}${currency.symbol}`;
|
||||||
|
}
|
||||||
|
if (currency.name) return `${value} ${currency.name}`;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MagazineGroup = {
|
||||||
|
magazineId: number | null;
|
||||||
|
magazineName: string | null;
|
||||||
|
items: ReleaseDetailData["magazineRefs"];
|
||||||
|
};
|
||||||
|
|
||||||
|
type IssueGroup = {
|
||||||
|
issueId: number;
|
||||||
|
issue: ReleaseDetailData["magazineRefs"][number]["issue"];
|
||||||
|
items: ReleaseDetailData["magazineRefs"];
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupMagazineRefs(refs: ReleaseDetailData["magazineRefs"]) {
|
||||||
|
const groups: MagazineGroup[] = [];
|
||||||
|
const lookup = new Map<string, MagazineGroup>();
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
const key = ref.magazineId != null ? `mag:${ref.magazineId}` : "mag:unknown";
|
||||||
|
let group = lookup.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = { magazineId: ref.magazineId, magazineName: ref.magazineName, items: [] };
|
||||||
|
lookup.set(key, group);
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
group.items.push(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupIssueRefs(refs: ReleaseDetailData["magazineRefs"]) {
|
||||||
|
const groups: IssueGroup[] = [];
|
||||||
|
const lookup = new Map<number, IssueGroup>();
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
const key = ref.issueId;
|
||||||
|
let group = lookup.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = { issueId: ref.issueId, issue: ref.issue, items: [] };
|
||||||
|
lookup.set(key, group);
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
group.items.push(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) {
|
||||||
|
if (!data) return <div className="alert alert-warning">Not found</div>;
|
||||||
|
|
||||||
|
const magazineGroups = groupMagazineRefs(data.magazineRefs);
|
||||||
|
const otherReleases = data.entryReleases.filter((r) => r.releaseSeq !== data.release.releaseSeq);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Releases", href: "/zxdb/releases" },
|
||||||
|
{ label: data.entry.title, href: `/zxdb/entries/${data.entry.id}` },
|
||||||
|
{ label: `Release #${data.release.releaseSeq}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="mb-0">Release #{data.release.releaseSeq}</h1>
|
||||||
|
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/entries/${data.entry.id}`}>
|
||||||
|
{data.entry.title}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3 mt-2">
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Release Summary</h5>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 160 }}>Entry</th>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Release Sequence</th>
|
||||||
|
<td>#{data.release.releaseSeq}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Release Date</th>
|
||||||
|
<td>
|
||||||
|
{data.release.year != null ? (
|
||||||
|
<span>
|
||||||
|
{data.release.year}
|
||||||
|
{data.release.month != null ? `/${String(data.release.month).padStart(2, "0")}` : ""}
|
||||||
|
{data.release.day != null ? `/${String(data.release.day).padStart(2, "0")}` : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Currency</th>
|
||||||
|
<td>
|
||||||
|
{data.release.currency.id ? (
|
||||||
|
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Price</th>
|
||||||
|
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Budget Price</th>
|
||||||
|
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Microdrive Price</th>
|
||||||
|
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Disk Price</th>
|
||||||
|
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Cartridge Price</th>
|
||||||
|
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Book ISBN</th>
|
||||||
|
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Book Pages</th>
|
||||||
|
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Other Releases</h5>
|
||||||
|
{otherReleases.length === 0 && <div className="text-secondary">No other releases</div>}
|
||||||
|
{otherReleases.length > 0 && (
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{otherReleases.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r.releaseSeq}
|
||||||
|
className="badge text-bg-light text-decoration-none"
|
||||||
|
href={`/zxdb/releases/${data.entry.id}/${r.releaseSeq}`}
|
||||||
|
>
|
||||||
|
#{r.releaseSeq}{r.year != null ? ` · ${r.year}` : ""}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Places (Magazines)</h5>
|
||||||
|
{magazineGroups.length === 0 && <div className="text-secondary">No magazine references</div>}
|
||||||
|
{magazineGroups.length > 0 && (
|
||||||
|
<div className="d-flex flex-column gap-3">
|
||||||
|
{magazineGroups.map((group) => (
|
||||||
|
<div key={group.magazineId ?? "unknown"}>
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="fw-semibold">
|
||||||
|
{group.magazineId != null ? (
|
||||||
|
<Link href={`/zxdb/magazines/${group.magazineId}`}>
|
||||||
|
{group.magazineName ?? `Magazine #${group.magazineId}`}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">Unknown magazine</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">{group.items.length} reference{group.items.length === 1 ? "" : "s"}</div>
|
||||||
|
</div>
|
||||||
|
{groupIssueRefs(group.items).map((issueGroup) => (
|
||||||
|
<div key={issueGroup.issueId} className="mt-2">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<Link href={`/zxdb/issues/${issueGroup.issueId}`}>Issue #{issueGroup.issueId}</Link>
|
||||||
|
<div className="text-secondary small">{formatIssue(issueGroup.issue) || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">
|
||||||
|
{issueGroup.items.length} reference{issueGroup.items.length === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive mt-2">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 80 }}>Page</th>
|
||||||
|
<th style={{ width: 120 }}>Type</th>
|
||||||
|
<th style={{ width: 100 }}>Original</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{issueGroup.items.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>{m.page}</td>
|
||||||
|
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
|
||||||
|
<td>{m.isOriginal ? "Yes" : "No"}</td>
|
||||||
|
<td>{m.scoreGroup || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Downloads</h5>
|
||||||
|
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
||||||
|
{data.downloads.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: 240 }}>MD5</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Comments</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.downloads.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>
|
||||||
|
) : null}
|
||||||
|
{d.machinetype.name ? (
|
||||||
|
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
|
||||||
|
) : null}
|
||||||
|
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{d.comments ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Scraps / Media</h5>
|
||||||
|
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>}
|
||||||
|
{data.scraps.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>Flags</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Rationale</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.scraps.map((s) => {
|
||||||
|
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
|
||||||
|
return (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
|
||||||
|
<td>
|
||||||
|
{s.link ? (
|
||||||
|
isHttp ? (
|
||||||
|
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a>
|
||||||
|
) : (
|
||||||
|
<span>{s.link}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-1 flex-wrap">
|
||||||
|
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
||||||
|
{s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
|
||||||
|
{s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
|
||||||
|
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||||
|
{s.language.name ? (
|
||||||
|
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link>
|
||||||
|
) : null}
|
||||||
|
{s.machinetype.name ? (
|
||||||
|
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link>
|
||||||
|
) : null}
|
||||||
|
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{s.rationale}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Issue Files</h5>
|
||||||
|
{data.files.length === 0 && <div className="text-secondary">No files linked</div>}
|
||||||
|
{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: 240 }}>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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/releases/${data.entry.id}/${data.release.releaseSeq}`}>Permalink</Link>
|
||||||
|
<Link className="btn btn-sm btn-outline-primary" href="/zxdb/releases">Back to Releases</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx
Normal file
16
src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import ReleaseDetailClient from "./ReleaseDetail";
|
||||||
|
import { getReleaseDetail } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "ZXDB Release",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }) {
|
||||||
|
const { entryId, releaseSeq } = await params;
|
||||||
|
const entryIdNum = Number(entryId);
|
||||||
|
const releaseSeqNum = Number(releaseSeq);
|
||||||
|
const data = await getReleaseDetail(entryIdNum, releaseSeqNum);
|
||||||
|
return <ReleaseDetailClient data={data} />;
|
||||||
|
}
|
||||||
57
src/app/zxdb/releases/page.tsx
Normal file
57
src/app/zxdb/releases/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import ReleasesExplorer from "./ReleasesExplorer";
|
||||||
|
import { listCasetypes, listFiletypes, listLanguages, listMachinetypes, listSchemetypes, listSourcetypes, 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 hasParams = Object.values(sp).some((value) => value !== undefined);
|
||||||
|
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, langs, machines, filetypes, schemes, sources, cases] = 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 }),
|
||||||
|
listLanguages(),
|
||||||
|
listMachinetypes(),
|
||||||
|
listFiletypes(),
|
||||||
|
listSchemetypes(),
|
||||||
|
listSourcetypes(),
|
||||||
|
listCasetypes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure the object passed to a Client Component is a plain JSON value
|
||||||
|
const initialPlain = JSON.parse(JSON.stringify(initial));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReleasesExplorer
|
||||||
|
initial={initialPlain}
|
||||||
|
initialLists={{
|
||||||
|
languages: JSON.parse(JSON.stringify(langs)),
|
||||||
|
machinetypes: JSON.parse(JSON.stringify(machines)),
|
||||||
|
filetypes: JSON.parse(JSON.stringify(filetypes)),
|
||||||
|
schemetypes: JSON.parse(JSON.stringify(schemes)),
|
||||||
|
sourcetypes: JSON.parse(JSON.stringify(sources)),
|
||||||
|
casetypes: JSON.parse(JSON.stringify(cases)),
|
||||||
|
}}
|
||||||
|
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
|
||||||
|
initialUrlHasParams={hasParams}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
@@ -22,4 +22,4 @@ export default function NavbarClient() {
|
|||||||
</Container>
|
</Container>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/env.ts
Normal file
50
src/env.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Optional file prefixes for ZXDB and WOS
|
||||||
|
ZXDB_FILE_PREFIX: z.string().optional(),
|
||||||
|
WOS_FILE_PREFIX: z.string().optional(),
|
||||||
|
|
||||||
|
// OIDC Configuration
|
||||||
|
OIDC_PROVIDER_URL: z.string().url().optional(),
|
||||||
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
|
|
||||||
|
// Redis cache and SMTP mail URLs
|
||||||
|
CACHE_URL: z.string().url().optional(),
|
||||||
|
MAIL_URL: z.string().url().optional(),
|
||||||
|
|
||||||
|
// System hostname for permalinks (mandatory)
|
||||||
|
HOSTNAME: z.string().min(1),
|
||||||
|
PROTO: z.string().startsWith("http"),
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
2369
src/server/repo/zxdb.ts
Normal file
2369
src/server/repo/zxdb.ts
Normal file
File diff suppressed because it is too large
Load Diff
648
src/server/schema/zxdb.ts
Normal file
648
src/server/schema/zxdb.ts
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext } 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"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Extended ZXDB schema coverage (structure-only) ----
|
||||||
|
|
||||||
|
export const booktypeins = mysqlTable("booktypeins", {
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
bookId: int("book_id").notNull(),
|
||||||
|
installment: smallint("installment").notNull().default(0),
|
||||||
|
volume: smallint("volume").notNull().default(0),
|
||||||
|
page: smallint("page").notNull().default(0),
|
||||||
|
isOriginal: tinyint("is_original").notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const countries = mysqlTable("countries", {
|
||||||
|
id: char("id", { length: 2 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const labeltypes = mysqlTable("labeltypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const licenses = mysqlTable("licenses", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
licensetypeId: char("licensetype_id", { length: 1 }).notNull(),
|
||||||
|
linkWikipedia: varchar("link_wikipedia", { length: 200 }),
|
||||||
|
linkSite: varchar("link_site", { length: 200 }),
|
||||||
|
comments: varchar("comments", { length: 500 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const licensetypes = mysqlTable("licensetypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const licensors = mysqlTable("licensors", {
|
||||||
|
licenseId: int("license_id").notNull(),
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const magreffeats = mysqlTable("magreffeats", {
|
||||||
|
magrefId: int("magref_id").notNull(),
|
||||||
|
featureId: int("feature_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const magreflinks = mysqlTable("magreflinks", {
|
||||||
|
magrefId: int("magref_id").notNull(),
|
||||||
|
link: varchar("link", { length: 250 }).notNull(),
|
||||||
|
hostId: tinyint("host_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const magtypes = mysqlTable("magtypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const members = mysqlTable("members", {
|
||||||
|
tagId: int("tag_id").notNull(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
categoryId: smallint("category_id").notNull().default(1),
|
||||||
|
memberSeq: smallint("member_seq"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notes = mysqlTable("notes", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
entryId: int("entry_id"),
|
||||||
|
labelId: int("label_id"),
|
||||||
|
notetypeId: char("notetype_id", { length: 1 }).notNull(),
|
||||||
|
text: mediumtext("text").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notetypes = mysqlTable("notetypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 100 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nvgs = mysqlTable("nvgs", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
title: varchar("title", { length: 250 }).notNull(),
|
||||||
|
entryId: int("entry_id"),
|
||||||
|
fileLink: varchar("file_link", { length: 250 }),
|
||||||
|
fileDate: varchar("file_date", { length: 50 }),
|
||||||
|
fileSize: int("file_size"),
|
||||||
|
filetypeId: tinyint("filetype_id"),
|
||||||
|
isDemo: tinyint("is_demo").notNull().default(0),
|
||||||
|
machinetypeId: tinyint("machinetype_id"),
|
||||||
|
comments: varchar("comments", { length: 500 }),
|
||||||
|
url: varchar("url", { length: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const origintypes = mysqlTable("origintypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const permissions = mysqlTable("permissions", {
|
||||||
|
websiteId: tinyint("website_id").notNull(),
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
permissiontypeId: char("permissiontype_id", { length: 1 }).notNull(),
|
||||||
|
text: varchar("text", { length: 300 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const permissiontypes = mysqlTable("permissiontypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const platforms = mysqlTable("platforms", {
|
||||||
|
id: tinyint("id").notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ports = mysqlTable("ports", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
title: varchar("title", { length: 250 }),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
platformId: tinyint("platform_id").notNull(),
|
||||||
|
isOfficial: tinyint("is_official").notNull(),
|
||||||
|
linkSystem: varchar("link_system", { length: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const prefixes = mysqlTable("prefixes", {
|
||||||
|
text: varchar("text", { length: 10 }).notNull().primaryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const prefixexempts = mysqlTable("prefixexempts", {
|
||||||
|
text: varchar("text", { length: 50 }).notNull().primaryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const relatedlicenses = mysqlTable("relatedlicenses", {
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
licenseId: int("license_id").notNull(),
|
||||||
|
isOfficial: tinyint("is_official").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const relations = mysqlTable("relations", {
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
originalId: int("original_id").notNull(),
|
||||||
|
relationtypeId: char("relationtype_id", { length: 1 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const relationtypes = mysqlTable("relationtypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
reciprocal: varchar("reciprocal", { length: 50 }).notNull(),
|
||||||
|
comments: varchar("comments", { length: 250 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remakes = mysqlTable("remakes", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
title: varchar("title", { length: 250 }).notNull(),
|
||||||
|
fileLink: varchar("file_link", { length: 250 }).notNull(),
|
||||||
|
fileDate: varchar("file_date", { length: 50 }),
|
||||||
|
fileSize: int("file_size"),
|
||||||
|
authors: varchar("authors", { length: 250 }),
|
||||||
|
platforms: varchar("platforms", { length: 200 }),
|
||||||
|
remakeYears: varchar("remake_years", { length: 100 }),
|
||||||
|
remakeStatus: varchar("remake_status", { length: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scores = mysqlTable("scores", {
|
||||||
|
websiteId: tinyint("website_id").notNull(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
score: decimal("score", { precision: 5, scale: 2 }).notNull(),
|
||||||
|
votes: int("votes").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scraps = mysqlTable("scraps", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
entryId: int("entry_id"),
|
||||||
|
releaseSeq: smallint("release_seq"),
|
||||||
|
fileLink: varchar("file_link", { length: 250 }),
|
||||||
|
fileDate: varchar("file_date", { length: 50 }),
|
||||||
|
fileSize: int("file_size"),
|
||||||
|
filetypeId: tinyint("filetype_id").notNull(),
|
||||||
|
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 }),
|
||||||
|
rationale: varchar("rationale", { length: 100 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchByAliases = mysqlTable("search_by_aliases", {
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
title: varchar("title", { length: 250 }).notNull(),
|
||||||
|
libraryTitle: varchar("library_title", { length: 300 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchByIssues = mysqlTable("search_by_issues", {
|
||||||
|
issueId: int("issue_id").notNull().primaryKey(),
|
||||||
|
name: varchar("name", { length: 300 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchByMagazines = mysqlTable("search_by_magazines", {
|
||||||
|
magazineId: smallint("magazine_id").notNull(),
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchByMagrefs = mysqlTable("search_by_magrefs", {
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
magrefId: int("magref_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchByOrigins = mysqlTable("search_by_origins", {
|
||||||
|
entryId: int("entry_id").notNull().primaryKey(),
|
||||||
|
libraryTitle: varchar("library_title", { length: 300 }).notNull(),
|
||||||
|
origintypeId: char("origintype_id", { length: 1 }).notNull(),
|
||||||
|
containerId: int("container_id"),
|
||||||
|
issueId: int("issue_id"),
|
||||||
|
dateYear: smallint("date_year"),
|
||||||
|
dateMonth: smallint("date_month"),
|
||||||
|
dateDay: smallint("date_day"),
|
||||||
|
publication: varchar("publication", { length: 300 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const spexAuthors = mysqlTable("spex_authors", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
name: varchar("name", { length: 150 }).notNull(),
|
||||||
|
labelId: int("label_id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const spexEntries = mysqlTable("spex_entries", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
title: varchar("title", { length: 150 }).notNull(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
releaseSeq: smallint("release_seq"),
|
||||||
|
pub1LabelId: int("pub1_label_id"),
|
||||||
|
pub2LabelId: int("pub2_label_id"),
|
||||||
|
pub3LabelId: int("pub3_label_id"),
|
||||||
|
genretypeId: tinyint("genretype_id"),
|
||||||
|
orgprice: decimal("orgprice", { precision: 5, scale: 2 }).notNull(),
|
||||||
|
repub2price: decimal("repub2price", { precision: 5, scale: 2 }).notNull(),
|
||||||
|
repub3price: decimal("repub3price", { precision: 5, scale: 2 }).notNull(),
|
||||||
|
diskprice: decimal("diskprice", { precision: 5, scale: 2 }).notNull(),
|
||||||
|
fgtkey: varchar("fgtkey", { length: 150 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tags = mysqlTable("tags", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
link: varchar("link", { length: 200 }),
|
||||||
|
comments: varchar("comments", { length: 1500 }),
|
||||||
|
tagtypeId: char("tagtype_id", { length: 1 }).notNull(),
|
||||||
|
toolId: int("tool_id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagtypes = mysqlTable("tagtypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const topics = mysqlTable("topics", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
topictypeId: char("topictype_id", { length: 1 }).notNull(),
|
||||||
|
labelId: int("label_id"),
|
||||||
|
magazineId: smallint("magazine_id"),
|
||||||
|
name: varchar("name", { length: 150 }).notNull(),
|
||||||
|
comments: varchar("comments", { length: 150 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const topictypes = mysqlTable("topictypes", {
|
||||||
|
id: char("id", { length: 1 }).notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zxsrAwards = mysqlTable("zxsr_awards", {
|
||||||
|
id: tinyint("id").notNull().primaryKey(),
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
magazineId: smallint("magazine_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zxsrCaptions = mysqlTable("zxsr_captions", {
|
||||||
|
magrefId: int("magref_id").notNull(),
|
||||||
|
captionSeq: smallint("caption_seq").notNull(),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
isBanner: tinyint("is_banner").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zxsrReviews = mysqlTable("zxsr_reviews", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
introText: longtext("intro_text"),
|
||||||
|
reviewText: longtext("review_text"),
|
||||||
|
reviewRating: varchar("review_rating", { length: 2000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zxsrScores = mysqlTable("zxsr_scores", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
magrefId: int("magref_id").notNull(),
|
||||||
|
scoreSeq: tinyint("score_seq"),
|
||||||
|
category: varchar("category", { length: 100 }).notNull(),
|
||||||
|
isOverall: tinyint("is_overall").notNull().default(0),
|
||||||
|
score: varchar("score", { length: 100 }),
|
||||||
|
comments: text("comments"),
|
||||||
|
});
|
||||||
@@ -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