Compare commits
84 Commits
deploy
...
feature/so
| Author | SHA1 | Date | |
|---|---|---|---|
| fe1dfa4170 | |||
| 5f84f482ab | |||
| 8624050614 | |||
| fc513c580b | |||
| e27a16eda1 | |||
| 5a6c536283 | |||
| 6b91fde972 | |||
| 9efedb5f2e | |||
| 51e1f10417 | |||
| 9bfebc1372 | |||
| edc937ad5d | |||
| f5ae89e888 | |||
| 944a2dc4d1 | |||
| b361201cf2 | |||
| b158bfc4a0 | |||
| 728b36e45e | |||
| f445aabcb4 | |||
| 32985c33b9 | |||
| 2e47b598c1 | |||
| ab7872b610 | |||
| 4b3d1ccc7b | |||
| 77b5e76a08 | |||
| cbee214a6b | |||
| 53eb9a1501 | |||
| 9807005305 | |||
| 24e08ce7b9 | |||
| 00a13e3289 | |||
| 2d4b1b2d5b | |||
| 79d161afe1 | |||
| 8a9c5395bd | |||
| 1e8925e631 | |||
| 2f93ed1774 | |||
| dc6db608cd | |||
| 762d13be55 | |||
| 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 | |||
| 417fd997a7 |
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
|
||||||
175
AGENTS.md
Normal file
175
AGENTS.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# 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.
|
||||||
|
- Supports optional local file mirroring via `ZXDB_LOCAL_FILEPATH` and `WOS_LOCAL_FILEPATH` env vars.
|
||||||
|
- 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 or update COMMIT_EDITMSG file if commits pending, await any user
|
||||||
|
edits, or additional instructions. Once told, commit all the changes
|
||||||
|
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.
|
||||||
|
- testing:
|
||||||
|
- **DO NOT** not restart the dev-server, use the already running one.
|
||||||
|
- Use tsc -noEmit to check for type errors
|
||||||
|
- **DO NOT** 'build' the application, Next.js build breaks the dev-server.
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- ZXDB setup and API usage: `docs/ZXDB.md`
|
||||||
101
README.md
101
README.md
@@ -1,36 +1,91 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
Spectrum Next Explorer
|
||||||
|
|
||||||
## Getting Started
|
A Next.js application for exploring the Spectrum Next ecosystem. It ships with:
|
||||||
|
|
||||||
First, run the development server:
|
- Register Explorer: parsed from `data/nextreg.txt`, with real‑time search and deep links
|
||||||
|
- ZXDB Explorer: a deep, cross‑linked browser for entries, labels, genres, languages, and machine types backed by a MySQL ZXDB instance
|
||||||
|
- Bootstrap 5 theme with light/dark support
|
||||||
|
|
||||||
```bash
|
Quick start
|
||||||
npm run dev
|
- Prerequisites: Node.js 20+, pnpm (recommended), access to a MySQL server for ZXDB (optional for Registers)
|
||||||
# or
|
- Install dependencies:
|
||||||
yarn dev
|
- `pnpm install`
|
||||||
# or
|
- Run in development (Turbopack, port 4000):
|
||||||
pnpm dev
|
- `pnpm dev` then open http://localhost:4000
|
||||||
# or
|
- Build and start (production):
|
||||||
bun dev
|
- `pnpm build`
|
||||||
```
|
- `pnpm start` (defaults to http://localhost:3000)
|
||||||
|
- Lint:
|
||||||
|
- `pnpm lint`
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Project scripts (package.json)
|
||||||
|
- `dev`: `PORT=4000 next dev --turbopack`
|
||||||
|
- `build`: `next build --turbopack`
|
||||||
|
- `start`: `next start`
|
||||||
|
- `deploy`: 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-prod`: push to `explorer.specnext.dev`
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
Routes
|
||||||
|
- `/` — Home
|
||||||
|
- `/registers` — Register Explorer
|
||||||
|
- `/zxdb` — ZXDB Explorer (hub)
|
||||||
|
- `/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)
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
ZXDB setup (database, env, and helper tables)
|
||||||
|
The Registers section works without any database. The ZXDB Explorer requires a MySQL ZXDB database and one environment variable.
|
||||||
|
|
||||||
## Learn More
|
1) Prepare the database (outside this app)
|
||||||
|
- Import ZXDB data into MySQL. If you want only structure, use `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` in this repo. For data, import ZXDB via your normal process.
|
||||||
|
- Create the helper search tables (required for fast search):
|
||||||
|
- Run `ZXDB/scripts/ZXDB_help_search.sql` against your ZXDB database.
|
||||||
|
- Create a read‑only role/user (recommended):
|
||||||
|
- Example (see `bin/import_mysql.sh`):
|
||||||
|
- Create role `zxdb_readonly`
|
||||||
|
- Grant `SELECT, SHOW VIEW` on database `zxdb`
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
2) Configure environment
|
||||||
|
- Copy `.env` from `example.env`.
|
||||||
|
- Set `ZXDB_URL` to a MySQL URL, e.g. `mysql://zxdb_readonly:password@hostname:3306/zxdb`.
|
||||||
|
- On startup, `src/env.ts` validates env vars (t3.gg pattern with Zod) and will fail fast if invalid.
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
3) Run the app
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`.
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
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.
|
||||||
|
|
||||||
## Deploy on Vercel
|
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}`
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
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.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Further reading
|
||||||
|
- ZXDB details and API usage: `docs/ZXDB.md`
|
||||||
|
- Agent/developer workflow and commit guidelines: `AGENTS.md`
|
||||||
|
|
||||||
|
License
|
||||||
|
- See `LICENSE.txt` for details.
|
||||||
|
|||||||
1
ZXDB
Submodule
1
ZXDB
Submodule
Submodule ZXDB added at dc2edad9ec
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}"
|
||||||
85
bin/import_mysql.sh
Normal file
85
bin/import_mysql.sh
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Parse connection details from ZXDB_URL in .env
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: .env file not found at $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ZXDB_URL=$(grep '^ZXDB_URL=' "$ENV_FILE" | cut -d= -f2-)
|
||||||
|
if [ -z "$ZXDB_URL" ]; then
|
||||||
|
echo "Error: ZXDB_URL not set in .env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unescape backslash-escaped characters (e.g. \$ -> $)
|
||||||
|
ZXDB_URL=$(echo "$ZXDB_URL" | sed 's/\\\(.\)/\1/g')
|
||||||
|
|
||||||
|
# Extract user, password, host, port, database from mysql://user:pass@host:port/db
|
||||||
|
DB_USER=$(echo "$ZXDB_URL" | sed -n 's|^mysql://\([^:]*\):.*|\1|p')
|
||||||
|
DB_PASS=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^:]*:\([^@]*\)@.*|\1|p')
|
||||||
|
DB_HOST=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^@]*@\([^:]*\):.*|\1|p')
|
||||||
|
DB_PORT=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^@]*@[^:]*:\([0-9]*\)/.*|\1|p')
|
||||||
|
DB_NAME=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^/]*/\(.*\)|\1|p')
|
||||||
|
|
||||||
|
MYSQL_ARGS="-u${DB_USER} -p${DB_PASS} -h${DB_HOST} -P${DB_PORT}"
|
||||||
|
|
||||||
|
echo "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\`;" | mysql $MYSQL_ARGS
|
||||||
|
mysql $MYSQL_ARGS < ZXDB/ZXDB_mysql.sql
|
||||||
|
{
|
||||||
|
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 IF NOT EXISTS 'zxdb_readonly';"
|
||||||
|
# echo "GRANT SELECT, SHOW VIEW ON \`zxdb\`.* TO 'zxdb_readonly';"
|
||||||
|
} | mysql --force $MYSQL_ARGS "$DB_NAME"
|
||||||
|
# ---- Reimport software_hashes from JSON snapshot if available ----
|
||||||
|
HASHES_SNAPSHOT="$SCRIPT_DIR/../data/zxdb/software_hashes.json"
|
||||||
|
if [ -f "$HASHES_SNAPSHOT" ]; then
|
||||||
|
echo "Reimporting software_hashes from $HASHES_SNAPSHOT ..."
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
(async () => {
|
||||||
|
const snap = JSON.parse(fs.readFileSync('$HASHES_SNAPSHOT', 'utf8'));
|
||||||
|
if (!snap.rows || snap.rows.length === 0) {
|
||||||
|
console.log(' No rows in snapshot, skipping.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pool = mysql.createPool({ uri: '$ZXDB_URL', connectionLimit: 1 });
|
||||||
|
await pool.query(\`
|
||||||
|
CREATE TABLE IF NOT EXISTS software_hashes (
|
||||||
|
download_id INT NOT NULL PRIMARY KEY,
|
||||||
|
md5 VARCHAR(32) NOT NULL,
|
||||||
|
crc32 VARCHAR(8) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
inner_path VARCHAR(500) NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sh_md5 (md5),
|
||||||
|
INDEX idx_sh_crc32 (crc32)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
\`);
|
||||||
|
await pool.query('TRUNCATE TABLE software_hashes');
|
||||||
|
// Batch insert in chunks of 500
|
||||||
|
const chunk = 500;
|
||||||
|
for (let i = 0; i < snap.rows.length; i += chunk) {
|
||||||
|
const batch = snap.rows.slice(i, i + chunk);
|
||||||
|
const values = batch.map(r => [r.download_id, r.md5, r.crc32, r.size_bytes, r.inner_path, r.updated_at]);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO software_hashes (download_id, md5, crc32, size_bytes, inner_path, updated_at) VALUES ?',
|
||||||
|
[values]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(' Imported ' + snap.rows.length + ' rows into software_hashes.');
|
||||||
|
await pool.end();
|
||||||
|
})().catch(e => { console.error(' Error reimporting software_hashes:', e.message); process.exit(0); });
|
||||||
|
"
|
||||||
|
else
|
||||||
|
echo "No software_hashes snapshot found at $HASHES_SNAPSHOT — skipping reimport."
|
||||||
|
fi
|
||||||
|
|
||||||
|
mysqldump --no-data -uroot -p -h${DB_HOST} -P${DB_PORT} "$DB_NAME" > 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"
|
||||||
498
bin/update-software-hashes.mjs
Executable file
498
bin/update-software-hashes.mjs
Executable file
@@ -0,0 +1,498 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Compute MD5, CRC32 and size for the inner tape file inside each download zip.
|
||||||
|
// Populates the `software_hashes` table and exports a JSON snapshot to
|
||||||
|
// data/zxdb/software_hashes.json for reimport after DB wipes.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node bin/update-software-hashes.mjs [flags]
|
||||||
|
//
|
||||||
|
// Flags:
|
||||||
|
// --rebuild-all Ignore state and reprocess every download
|
||||||
|
// --rebuild-missing Only process downloads not yet in software_hashes
|
||||||
|
// --start-from-id=N Start processing from download id N
|
||||||
|
// --export-only Skip processing, just export current table to JSON
|
||||||
|
// --quiet Reduce log output
|
||||||
|
// --verbose Force verbose output (default)
|
||||||
|
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import dotenvExpand from "dotenv-expand";
|
||||||
|
dotenvExpand.expand(dotenv.config());
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { createReadStream } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
|
import { Transform } from "stream";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
// ---- CLI flags ----
|
||||||
|
const ARGV = new Set(process.argv.slice(2));
|
||||||
|
const QUIET = ARGV.has("--quiet");
|
||||||
|
const VERBOSE = ARGV.has("--verbose") || !QUIET;
|
||||||
|
const REBUILD_ALL = ARGV.has("--rebuild-all");
|
||||||
|
const REBUILD_MISSING = ARGV.has("--rebuild-missing");
|
||||||
|
const EXPORT_ONLY = ARGV.has("--export-only");
|
||||||
|
|
||||||
|
// Parse --start-from-id=N
|
||||||
|
let CLI_START_FROM = 0;
|
||||||
|
for (const arg of process.argv.slice(2)) {
|
||||||
|
const m = arg.match(/^--start-from-id=(\d+)$/);
|
||||||
|
if (m) CLI_START_FROM = parseInt(m[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInfo(msg) { if (VERBOSE) console.log(msg); }
|
||||||
|
function logWarn(msg) { console.warn(msg); }
|
||||||
|
function logError(msg) { console.error(msg); }
|
||||||
|
|
||||||
|
// ---- Environment ----
|
||||||
|
const envSchema = z.object({
|
||||||
|
ZXDB_URL: z.string().url().refine((s) => s.startsWith("mysql://"), {
|
||||||
|
message: "ZXDB_URL must be a valid mysql:// URL",
|
||||||
|
}),
|
||||||
|
CDN_CACHE: z.string().min(1, "CDN_CACHE must be set to the local CDN mirror root"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
if (!parsedEnv.success) {
|
||||||
|
logError("Invalid environment variables:\n" + JSON.stringify(parsedEnv.error.format(), null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const { ZXDB_URL, CDN_CACHE } = parsedEnv.data;
|
||||||
|
|
||||||
|
const SNAPSHOT_PATH = path.join(PROJECT_ROOT, "data", "zxdb", "software_hashes.json");
|
||||||
|
const STATE_FILE = path.join(CDN_CACHE, ".update-software-hashes.state.json");
|
||||||
|
|
||||||
|
// Filetype IDs for tape images
|
||||||
|
const TAPE_FILETYPE_IDS = [8, 22];
|
||||||
|
|
||||||
|
// Tape file extensions in priority order (most common first)
|
||||||
|
const TAPE_EXTENSIONS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
|
||||||
|
|
||||||
|
// ---- DB ----
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
uri: ZXDB_URL,
|
||||||
|
connectionLimit: 10,
|
||||||
|
maxPreparedStatements: 256,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Path mapping (mirrors sync-downloads.mjs) ----
|
||||||
|
function toLocalPath(fileLink) {
|
||||||
|
if (fileLink.startsWith("/zxdb/sinclair/")) {
|
||||||
|
return path.join(CDN_CACHE, "SC", fileLink.slice("/zxdb/sinclair".length));
|
||||||
|
}
|
||||||
|
if (fileLink.startsWith("/pub/sinclair/")) {
|
||||||
|
return path.join(CDN_CACHE, "WoS", fileLink.slice("/pub/sinclair".length));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- State management ----
|
||||||
|
async function loadState() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(STATE_FILE, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStateAtomic(state) {
|
||||||
|
const tmp = STATE_FILE + ".tmp";
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
|
||||||
|
await fs.rename(tmp, STATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Zip extraction ----
|
||||||
|
|
||||||
|
// Use Node.js built-in (node:zlib for deflate) + manual zip parsing
|
||||||
|
// to avoid external dependencies. Zip files in ZXDB are simple (no encryption, single file).
|
||||||
|
|
||||||
|
async function extractZipContents(zipPath, contentsDir) {
|
||||||
|
const { execFile } = await import("child_process");
|
||||||
|
const { promisify } = await import("util");
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
await fs.mkdir(contentsDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use system unzip, quoting the path to handle brackets in filenames
|
||||||
|
await execFileAsync("unzip", ["-o", "-d", contentsDir, zipPath], {
|
||||||
|
maxBuffer: 50 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// unzip returns exit code 1 for warnings (e.g. "appears to use backslashes")
|
||||||
|
// which is non-fatal — only fail on actual extraction errors
|
||||||
|
if (err.code !== 1) {
|
||||||
|
throw new Error(`unzip failed for ${zipPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Find tape file inside _CONTENTS ----
|
||||||
|
|
||||||
|
async function findTapeFile(contentsDir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(contentsDir, { recursive: true, withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all tape files grouped by extension priority
|
||||||
|
const candidates = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
const priority = TAPE_EXTENSIONS.indexOf(ext);
|
||||||
|
if (priority === -1) continue;
|
||||||
|
|
||||||
|
const fullPath = path.join(entry.parentPath ?? entry.path, entry.name);
|
||||||
|
candidates.push({ path: fullPath, ext, priority, name: entry.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
// Sort by priority (lowest index = highest priority), then alphabetically
|
||||||
|
candidates.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
// Return the best candidate
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hash computation ----
|
||||||
|
|
||||||
|
async function computeHashes(filePath) {
|
||||||
|
const md5 = createHash("md5");
|
||||||
|
let crc = 0xFFFFFFFF;
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
// CRC32 lookup table
|
||||||
|
const crcTable = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
crcTable[i] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = new Transform({
|
||||||
|
transform(chunk, encoding, callback) {
|
||||||
|
md5.update(chunk);
|
||||||
|
size += chunk.length;
|
||||||
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
|
crc = crcTable[(crc ^ chunk[i]) & 0xFF] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
// Pipe through transform (which computes hashes) and discard output
|
||||||
|
await pipeline(stream, transform, async function* (source) {
|
||||||
|
for await (const _ of source) { /* drain */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
const crc32Final = ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0");
|
||||||
|
return {
|
||||||
|
md5: md5.digest("hex"),
|
||||||
|
crc32: crc32Final,
|
||||||
|
sizeBytes: size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ensure software_hashes table exists ----
|
||||||
|
|
||||||
|
async function ensureTable() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS software_hashes (
|
||||||
|
download_id INT NOT NULL PRIMARY KEY,
|
||||||
|
md5 VARCHAR(32) NOT NULL,
|
||||||
|
crc32 VARCHAR(8) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
inner_path VARCHAR(500) NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sh_md5 (md5),
|
||||||
|
INDEX idx_sh_crc32 (crc32)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON export ----
|
||||||
|
|
||||||
|
async function exportSnapshot() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT download_id, md5, crc32, size_bytes, inner_path, updated_at FROM software_hashes ORDER BY download_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
count: rows.length,
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
download_id: r.download_id,
|
||||||
|
md5: r.md5,
|
||||||
|
crc32: r.crc32,
|
||||||
|
size_bytes: Number(r.size_bytes),
|
||||||
|
inner_path: r.inner_path,
|
||||||
|
updated_at: r.updated_at instanceof Date ? r.updated_at.toISOString() : r.updated_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(path.dirname(SNAPSHOT_PATH), { recursive: true });
|
||||||
|
|
||||||
|
// Atomic write
|
||||||
|
const tmp = SNAPSHOT_PATH + ".tmp";
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(snapshot, null, 2), "utf8");
|
||||||
|
await fs.rename(tmp, SNAPSHOT_PATH);
|
||||||
|
|
||||||
|
logInfo(`Exported ${rows.length} rows to ${SNAPSHOT_PATH}`);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Main processing loop ----
|
||||||
|
|
||||||
|
let currentState = null;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureTable();
|
||||||
|
|
||||||
|
if (EXPORT_ONLY) {
|
||||||
|
const count = await exportSnapshot();
|
||||||
|
logInfo(`Export complete: ${count} rows.`);
|
||||||
|
await pool.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine start point
|
||||||
|
const prior = await loadState();
|
||||||
|
let resumeFrom = CLI_START_FROM;
|
||||||
|
if (!REBUILD_ALL && !CLI_START_FROM && prior?.lastProcessedId) {
|
||||||
|
resumeFrom = prior.lastProcessedId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
currentState = {
|
||||||
|
version: 1,
|
||||||
|
startedAt,
|
||||||
|
updatedAt: startedAt,
|
||||||
|
startFromId: resumeFrom,
|
||||||
|
lastProcessedId: prior?.lastProcessedId ?? -1,
|
||||||
|
processed: 0,
|
||||||
|
hashed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query tape-image downloads
|
||||||
|
const placeholders = TAPE_FILETYPE_IDS.map(() => "?").join(", ");
|
||||||
|
|
||||||
|
let rows;
|
||||||
|
if (REBUILD_MISSING) {
|
||||||
|
// Only fetch downloads that don't already have a hash
|
||||||
|
[rows] = await pool.query(
|
||||||
|
`SELECT d.id, d.file_link, d.file_size FROM downloads d
|
||||||
|
LEFT JOIN software_hashes sh ON sh.download_id = d.id
|
||||||
|
WHERE d.filetype_id IN (${placeholders}) AND sh.download_id IS NULL
|
||||||
|
ORDER BY d.id ASC`,
|
||||||
|
TAPE_FILETYPE_IDS
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
[rows] = await pool.query(
|
||||||
|
`SELECT id, file_link, file_size FROM downloads
|
||||||
|
WHERE filetype_id IN (${placeholders}) AND id >= ?
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[...TAPE_FILETYPE_IDS, resumeFrom]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get total count for progress display
|
||||||
|
const [totalRows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM downloads WHERE filetype_id IN (${placeholders})`,
|
||||||
|
TAPE_FILETYPE_IDS
|
||||||
|
);
|
||||||
|
const total = totalRows[0].cnt;
|
||||||
|
|
||||||
|
const mode = REBUILD_MISSING ? "missing only" : REBUILD_ALL ? "rebuild all" : `from id >= ${resumeFrom}`;
|
||||||
|
logInfo(`Processing ${rows.length} tape-image downloads (total in DB: ${total}, mode: ${mode})`);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let hashed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const { id, file_link: fileLink } = row;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localZip = toLocalPath(fileLink);
|
||||||
|
if (!localZip) {
|
||||||
|
// /denied/ and other non-hosted prefixes — skip silently
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
if (processed % 500 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if zip exists locally
|
||||||
|
try {
|
||||||
|
await fs.access(localZip);
|
||||||
|
} catch {
|
||||||
|
// Zip not synced yet — skip silently
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
if (processed % 500 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check/create _CONTENTS
|
||||||
|
const contentsDir = localZip + "_CONTENTS";
|
||||||
|
let contentsExisted = false;
|
||||||
|
try {
|
||||||
|
await fs.access(contentsDir);
|
||||||
|
contentsExisted = true;
|
||||||
|
} catch {
|
||||||
|
// Need to extract
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentsExisted) {
|
||||||
|
try {
|
||||||
|
await extractZipContents(localZip, contentsDir);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn(` [${id}] Extract failed: ${err.message}`);
|
||||||
|
errors++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tape file
|
||||||
|
const tapeFile = await findTapeFile(contentsDir);
|
||||||
|
if (!tapeFile) {
|
||||||
|
// No tape file found inside zip — unusual but not fatal
|
||||||
|
if (VERBOSE) logWarn(` [${id}] No tape file in ${contentsDir}`);
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hashes
|
||||||
|
const hashes = await computeHashes(tapeFile.path);
|
||||||
|
|
||||||
|
// Relative path inside _CONTENTS for the inner_path column
|
||||||
|
const innerPath = path.relative(contentsDir, tapeFile.path);
|
||||||
|
|
||||||
|
// Upsert
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO software_hashes (download_id, md5, crc32, size_bytes, inner_path, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
md5 = VALUES(md5),
|
||||||
|
crc32 = VALUES(crc32),
|
||||||
|
size_bytes = VALUES(size_bytes),
|
||||||
|
inner_path = VALUES(inner_path),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[id, hashes.md5, hashes.crc32, hashes.sizeBytes, innerPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
hashed++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (processed % 100 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
logInfo(`... processed=${processed}/${rows.length}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError(` [${id}] Unexpected error: ${err.message}`);
|
||||||
|
errors++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
currentState.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final state save
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
|
||||||
|
logInfo(`\nProcessing complete: processed=${processed}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
|
||||||
|
|
||||||
|
// Export snapshot
|
||||||
|
logInfo("\nExporting JSON snapshot...");
|
||||||
|
await exportSnapshot();
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
logInfo("Done.");
|
||||||
|
|
||||||
|
async function checkpoint() {
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Graceful shutdown ----
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
logWarn("\nInterrupted (SIGINT). Writing state...");
|
||||||
|
try {
|
||||||
|
if (currentState) {
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
logWarn(`State saved at: ${STATE_FILE}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state on SIGINT: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
try { await pool.end(); } catch {}
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run
|
||||||
|
main().catch(async (err) => {
|
||||||
|
logError(`Fatal error: ${err.message}\n${err.stack || "<no stack>"}`);
|
||||||
|
try {
|
||||||
|
if (currentState) {
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
currentState.error = { message: err.message, stack: err.stack };
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state on fatal: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
try { await pool.end(); } catch {}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
0
data/zxdb/.gitkeep
Normal file
0
data/zxdb/.gitkeep
Normal file
263686
data/zxdb/software_hashes.json
Normal file
263686
data/zxdb/software_hashes.json
Normal file
File diff suppressed because it is too large
Load Diff
147
docs/ZXDB.md
Normal file
147
docs/ZXDB.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### Local File Mirrors
|
||||||
|
|
||||||
|
The explorer can optionally show "Local Mirror" links for downloads if you have local copies of the ZXDB and World of Spectrum (WoS) file archives.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
To enable local mirrors, set the following variables in your `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Absolute paths to your local mirrors
|
||||||
|
ZXDB_LOCAL_FILEPATH=/path/to/your/zxdb/mirror
|
||||||
|
WOS_LOCAL_FILEPATH=/path/to/your/wos/mirror
|
||||||
|
|
||||||
|
# Optional: Remote path prefixes to strip from database links before prepending local paths
|
||||||
|
ZXDB_FILE_PREFIX=/zxdb/sinclair/
|
||||||
|
WOS_FILE_PREFIX=/pub/sinclair/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### How it works
|
||||||
|
|
||||||
|
1. The app identifies if a download link matches `ZXDB_FILE_PREFIX` or `WOS_FILE_PREFIX`.
|
||||||
|
2. It strips the prefix from the database link.
|
||||||
|
3. It joins the remaining relative path to the corresponding `*_LOCAL_FILEPATH`.
|
||||||
|
4. It checks if the file exists on the local disk.
|
||||||
|
5. If the file exists and the environment variable is set, a "Local Mirror" link is displayed in the UI, pointing to a proxy download API (`/api/zxdb/download`).
|
||||||
|
|
||||||
|
Note: Obtaining these mirrors is left as an exercise to the host. The paths do not need to share a common parent directory. Both mirrors are optional and independent; you can configure one, both, or neither.
|
||||||
|
|
||||||
|
## 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.
|
||||||
30
docs/architecture.md
Normal file
30
docs/architecture.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Architecture
|
||||||
|
|
||||||
|
Overview
|
||||||
|
- Framework: Next.js App Router (React 19)
|
||||||
|
- Styling: Bootstrap 5 with React-Bootstrap components and project SASS overrides
|
||||||
|
- Theming: Light/Dark theme set via data-bs-theme on <html>, initialized from a cookie or system preference in `src/app/layout.tsx`
|
||||||
|
|
||||||
|
Key paths
|
||||||
|
- App entry/layout: `src/app/layout.tsx`
|
||||||
|
- Global styles: `src/scss/nbn.scss` (imports Bootstrap, a Bootswatch-like layer, and project styles)
|
||||||
|
- Navbar: `src/components/Navbar.tsx`
|
||||||
|
- Register Explorer: `src/app/registers/*`
|
||||||
|
- Register parsing utilities: `src/utils/register_parser.ts` and `src/utils/register_parsers/*`
|
||||||
|
- Data: `data/nextreg.txt`
|
||||||
|
|
||||||
|
Styling & SASS
|
||||||
|
- `src/scss/nbn.scss` imports Bootstrap and local overrides in this order:
|
||||||
|
1. `variables` (custom Bootstrap variables)
|
||||||
|
2. Bootstrap core
|
||||||
|
3. `bootswatch` (theme tweaks)
|
||||||
|
4. `explorer` (project-specific styles)
|
||||||
|
|
||||||
|
Theming bootstrap
|
||||||
|
- On the server, `layout.tsx` reads the `NBN-theme` cookie (light/dark) and sets `data-bs-theme` on the HTML element.
|
||||||
|
- On the client, an inline script in the head ensures no flash of incorrect theme by immediately setting the attribute based on cookie or user preference.
|
||||||
|
|
||||||
|
Development scripts
|
||||||
|
- Dev: `pnpm dev` (port 4000 with Turbopack)
|
||||||
|
- Build: `pnpm build`
|
||||||
|
- Start: `pnpm start` (defaults to port 3000)
|
||||||
38
docs/getting-started.md
Normal file
38
docs/getting-started.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
Getting Started
|
||||||
|
|
||||||
|
This project is a Next.js app for exploring the Spectrum Next hardware. It uses the App Router, Bootstrap 5, and React-Bootstrap.
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
- Node.js 20 or newer
|
||||||
|
- pnpm (recommended) or npm/yarn
|
||||||
|
|
||||||
|
Install
|
||||||
|
- pnpm install
|
||||||
|
- or: npm install
|
||||||
|
|
||||||
|
Run in development
|
||||||
|
- The dev server runs on port 4000 using Turbopack
|
||||||
|
- Command: pnpm dev
|
||||||
|
- Then open: http://localhost:4000
|
||||||
|
|
||||||
|
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: pnpm build
|
||||||
|
- Start: pnpm start
|
||||||
|
- Default start port: http://localhost:3000
|
||||||
|
|
||||||
|
Lint
|
||||||
|
- pnpm lint
|
||||||
|
|
||||||
|
Deployment shortcuts
|
||||||
|
- 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-prod: push the current branch to explorer.specnext.dev
|
||||||
|
- Ensure the corresponding Git remotes are configured locally before using these.
|
||||||
10
docs/index.md
Normal file
10
docs/index.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Spectrum Next Explorer — Documentation
|
||||||
|
|
||||||
|
Welcome to the Spectrum Next Explorer docs. This site provides an overview of the project, how to develop and contribute, and details about key features like the Register Explorer and its search/deep‑linking capability.
|
||||||
|
|
||||||
|
- Getting Started: ./getting-started.md
|
||||||
|
- Architecture: ./architecture.md
|
||||||
|
- Register Explorer: ./registers.md
|
||||||
|
- ZXDB Explorer: ./ZXDB.md
|
||||||
|
|
||||||
|
If you’re browsing on GitHub, the main README also links to these documents.
|
||||||
75
docs/plans/plan_feature-software-hashes_implimentation.md
Normal file
75
docs/plans/plan_feature-software-hashes_implimentation.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# WIP: Software Hashes
|
||||||
|
|
||||||
|
**Branch:** `feature/software-hashes`
|
||||||
|
**Started:** 2026-02-17
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Implements [docs/plans/software-hashes.md](software-hashes.md) — a derived `software_hashes` table storing MD5, CRC32 and size for tape-image contents extracted from download zips.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- [x] Create `data/zxdb/` directory (for JSON snapshot)
|
||||||
|
- [x] Add `software_hashes` Drizzle schema model
|
||||||
|
- [x] Create `bin/update-software-hashes.mjs` — main pipeline script
|
||||||
|
- [x] DB query for tape-image downloads (filetype_id IN 8, 22)
|
||||||
|
- [x] Resolve local zip path via CDN mapping (uses CDN_CACHE env var)
|
||||||
|
- [x] Extract `_CONTENTS` (skip if exists)
|
||||||
|
- [x] Find tape file (.tap/.tzx/.pzx/.csw) with priority order
|
||||||
|
- [x] Compute MD5, CRC32, size_bytes
|
||||||
|
- [x] Upsert into software_hashes
|
||||||
|
- [x] State file for resume support
|
||||||
|
- [x] JSON export after bulk update (atomic write)
|
||||||
|
- [x] Update `bin/import_mysql.sh` to reimport snapshot on DB wipe
|
||||||
|
- [x] Add pnpm script entries
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### 2026-02-17T16:00Z
|
||||||
|
- Started work. Branch created from `main` at `b361201`.
|
||||||
|
- Explored codebase: understood DB schema, CDN mapping, import pipeline.
|
||||||
|
- Key findings:
|
||||||
|
- filetype_id 8 = "Tape image" (33,427 rows), 22 = "BUGFIX tape image" (98 rows)
|
||||||
|
- CDN_CACHE = /Volumes/McFiver/CDN, paths: SC/ (zxdb) and WoS/ (pub)
|
||||||
|
- `_CONTENTS` dirs exist in WoS but not yet in SC
|
||||||
|
- data/zxdb/ directory needs creation
|
||||||
|
- import_mysql.sh needs software_hashes reimport step
|
||||||
|
|
||||||
|
### 2026-02-17T16:04Z
|
||||||
|
- Implemented Drizzle schema model for `software_hashes`.
|
||||||
|
- Created `bin/update-software-hashes.mjs` pipeline script.
|
||||||
|
- Updated `bin/import_mysql.sh` with JSON snapshot reimport.
|
||||||
|
- Added `update:hashes` and `export:hashes` pnpm scripts.
|
||||||
|
|
||||||
|
### 2026-02-17T16:09Z
|
||||||
|
- First full run completed successfully:
|
||||||
|
- 33,525 total tape-image downloads in DB
|
||||||
|
- 32,305 rows hashed and inserted into software_hashes
|
||||||
|
- ~1,220 skipped (missing local zips, `/denied/` prefix, `.p` ZX81 files with no tape content)
|
||||||
|
- JSON snapshot exported: 7.2MB, 32,305 rows at `data/zxdb/software_hashes.json`
|
||||||
|
- All plan steps verified working.
|
||||||
|
|
||||||
|
## Decisions & Notes
|
||||||
|
|
||||||
|
- Target filetype IDs: 8 and 22 (tape image + bugfix tape image).
|
||||||
|
- Tape file priority: .tap > .tzx > .pzx > .csw (most common first).
|
||||||
|
- CDN_CACHE comes from env var (not hard-coded, unlike sync-downloads.mjs).
|
||||||
|
- JSON snapshot at data/zxdb/software_hashes.json (7.2MB, committed to repo).
|
||||||
|
- Node.js built-in `crypto` for MD5; custom CRC32 lookup table (no external deps).
|
||||||
|
- `inner_path` column added (not in original plan) to record which file inside the zip was hashed.
|
||||||
|
- `/denied/` and `/nvg/` prefix downloads (~443) are logged and skipped (no local mirror).
|
||||||
|
- `.p` files (ZX81 programs) categorized as tape images but contain no .tap/.tzx/.pzx/.csw — logged as "no tape file".
|
||||||
|
- Uses system `unzip` for extraction (handles bracket-heavy filenames via `execFile` not shell).
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
b361201 - Ready to start adding hashes
|
||||||
|
944a2dc - wip: start feature/software-hashes — init progress tracker
|
||||||
|
f5ae89e - feat: add software_hashes table schema and reimport pipeline
|
||||||
|
edc937a - feat: add update-software-hashes.mjs pipeline script
|
||||||
|
9bfebc1 - feat: add initial software_hashes JSON snapshot (32,305 rows)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# WIP: Tape Identifier Dropzone
|
||||||
|
|
||||||
|
**Branch:** `feature/software-hashes`
|
||||||
|
**Started:** 2026-02-17
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Implements the tape identifier feature from [docs/plans/tape-identifier.md](tape-identifier.md).
|
||||||
|
|
||||||
|
Drop a tape file on the /zxdb page → client computes MD5 + size → server action looks up `software_hashes` → returns identified ZXDB entry.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- [x] Add `lookupByMd5()` to `src/server/repo/zxdb.ts`
|
||||||
|
- [x] Create `src/utils/md5.ts` — pure-JS MD5 for browser
|
||||||
|
- [x] Create `src/app/zxdb/actions.ts` — server action `identifyTape`
|
||||||
|
- [x] Create `src/app/zxdb/TapeIdentifier.tsx` — client component with dropzone
|
||||||
|
- [x] Insert `<TapeIdentifier />` into `src/app/zxdb/page.tsx`
|
||||||
|
- [ ] Verify on http://localhost:4000/zxdb
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### 2026-02-17T00:00
|
||||||
|
- Started work. Continuing on `feature/software-hashes` at `e27a16e`.
|
||||||
|
|
||||||
|
### 2026-02-17T00:01
|
||||||
|
- All implementation complete. Type check passes. Ready for visual verification.
|
||||||
|
|
||||||
|
## Decisions & Notes
|
||||||
|
|
||||||
|
- Uses RSC server actions (not API routes) to discourage bulk scripting.
|
||||||
|
- MD5 computed client-side; file never leaves the browser.
|
||||||
|
- No new npm dependencies — pure-JS MD5 implementation (~130 lines).
|
||||||
|
- TapeIdentifier placed between hero and "Start exploring" grid in a row layout with explanatory text alongside.
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
fc513c5 - wip: start tape identifier — init progress tracker
|
||||||
|
8624050 - feat: add tape identifier dropzone on /zxdb
|
||||||
155
docs/plans/software-hashes.md
Normal file
155
docs/plans/software-hashes.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Software Hashes Plan
|
||||||
|
|
||||||
|
Plan for adding a derived `software_hashes` table, its update pipeline, and JSON snapshot lifecycle to survive DB wipes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Goals and Scope (Plan Step 1)
|
||||||
|
|
||||||
|
- Create and maintain `software_hashes` for (at this stage) tape-image downloads.
|
||||||
|
- Preserve existing `_CONTENTS` folders; only create missing ones.
|
||||||
|
- Export `software_hashes` to JSON after each bulk update.
|
||||||
|
- Reimport `software_hashes` JSON during DB wipe in `bin/import_mysql.sh` (or a helper script it invokes).
|
||||||
|
- Ensure all scripts are idempotent and resume-safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Confirm Pipeline Touchpoints (Plan Step 2)
|
||||||
|
|
||||||
|
- Verify `bin/import_mysql.sh` is the authoritative DB wipe/import entry point.
|
||||||
|
- Confirm `bin/sync-downloads.mjs` remains responsible only for CDN cache sync.
|
||||||
|
- Confirm `src/server/schema/zxdb.ts` uses `downloads.id` as the natural FK target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Define Data Model: `software_hashes` (Plan Step 3)
|
||||||
|
|
||||||
|
### Table naming and FK alignment
|
||||||
|
|
||||||
|
- Table: `software_hashes`.
|
||||||
|
- FK: `download_id` → `downloads.id`.
|
||||||
|
- Column names follow existing DB `snake_case` conventions.
|
||||||
|
|
||||||
|
### Planned columns
|
||||||
|
|
||||||
|
- `download_id` (PK or unique index; FK to `downloads.id`)
|
||||||
|
- `md5`
|
||||||
|
- `crc32`
|
||||||
|
- `size_bytes`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
### Planned indexes / constraints
|
||||||
|
|
||||||
|
- Unique index on `download_id`.
|
||||||
|
- Index on `md5` for reverse lookup.
|
||||||
|
- Index on `crc32` for reverse lookup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Define JSON Snapshot Format (Plan Step 4)
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
- Default: `data/zxdb/software_hashes.json` (or another agreed path).
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exportedAt": "2026-02-17T15:18:00.000Z",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"download_id": 123,
|
||||||
|
"md5": "...",
|
||||||
|
"crc32": "...",
|
||||||
|
"size_bytes": 12345,
|
||||||
|
"updated_at": "2026-02-17T15:18:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Planned import policy
|
||||||
|
|
||||||
|
- If snapshot exists: truncate `software_hashes` and bulk insert.
|
||||||
|
- If snapshot missing: log and continue without error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Implement Tape Image Update Workflow (Plan Step 5)
|
||||||
|
|
||||||
|
### Planned script
|
||||||
|
|
||||||
|
- `bin/update-software-hashes.mjs` (name can be adjusted).
|
||||||
|
|
||||||
|
### Planned input dataset
|
||||||
|
|
||||||
|
- Query `downloads` for tape-image rows (filter by `filetype_id` or joined `filetypes` table).
|
||||||
|
|
||||||
|
### Planned per-item process
|
||||||
|
|
||||||
|
1. Resolve local zip path using the same CDN mapping used by `sync-downloads`.
|
||||||
|
2. Compute `_CONTENTS` folder name: `<zip filename>_CONTENTS` (exact match).
|
||||||
|
3. If `_CONTENTS` exists, keep it untouched.
|
||||||
|
4. If missing, extract zip into `_CONTENTS` using a library that avoids shell expansion issues with brackets.
|
||||||
|
5. Locate tape file inside (`.tap`, `.tzx`, `.pzx`, `.csw`):
|
||||||
|
- Apply a deterministic priority order.
|
||||||
|
- If multiple candidates remain, log and skip (or record ambiguity).
|
||||||
|
6. Compute `md5`, `crc32`, and `size_bytes` for the selected file.
|
||||||
|
7. Upsert into `software_hashes` keyed by `download_id`.
|
||||||
|
|
||||||
|
### Planned error handling
|
||||||
|
|
||||||
|
- Log missing zips or missing tape files.
|
||||||
|
- Continue after recoverable errors; fail only on critical DB errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Implement JSON Export Lifecycle (Plan Step 6)
|
||||||
|
|
||||||
|
- After each bulk update, export `software_hashes` to JSON.
|
||||||
|
- Write atomically (temp file + rename).
|
||||||
|
- Include `exportedAt` timestamp in snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Reimport During Wipe (`bin/import_mysql.sh`) (Plan Step 7)
|
||||||
|
|
||||||
|
### Planned placement
|
||||||
|
|
||||||
|
- Immediately after database creation and ZXDB SQL import completes.
|
||||||
|
|
||||||
|
### Planned behavior
|
||||||
|
|
||||||
|
- Attempt to read JSON snapshot.
|
||||||
|
- If present, truncate and reinsert `software_hashes`.
|
||||||
|
- Log imported row count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Add Idempotency and Resume Support (Plan Step 8)
|
||||||
|
|
||||||
|
- State file similar to `.sync-downloads.state.json` to track last `download_id` processed.
|
||||||
|
- CLI flags:
|
||||||
|
- `--resume` (default)
|
||||||
|
- `--start-from-id`
|
||||||
|
- `--rebuild-all`
|
||||||
|
- Reprocess when zip file size or mtime changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Validation Checklist (Plan Step 9)
|
||||||
|
|
||||||
|
- `_CONTENTS` folders are never deleted.
|
||||||
|
- Hashes match expected MD5/CRC32 for known samples.
|
||||||
|
- JSON snapshot is created and reimported correctly.
|
||||||
|
- Reverse lookup by `md5`/`crc32`/`size_bytes` identifies misnamed files.
|
||||||
|
- Script can resume safely after interruption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Open Questions / Confirmations (Plan Step 10)
|
||||||
|
|
||||||
|
- Final `software_hashes` column list and types.
|
||||||
|
- Exact JSON snapshot path.
|
||||||
|
- Filetype IDs that map to “Tape Image” in `downloads`.
|
||||||
67
docs/plans/tape-identifier.md
Normal file
67
docs/plans/tape-identifier.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Plan: Tape Identifier Dropzone on /zxdb
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We have 32,960 rows in `software_hashes` with MD5, CRC32, size, and inner_path for tape-image contents. This feature exposes that data to users: drop a tape file, get it identified against the ZXDB database.
|
||||||
|
|
||||||
|
Uses RSC (server actions) rather than an API endpoint to make bulk scripted identification harder.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Client-side:** Compute MD5 + file size in the browser, then call a server action with just those two values (file never leaves the client).
|
||||||
|
|
||||||
|
**Server-side:** A Next.js Server Action looks up `software_hashes` by MD5 (and optionally size_bytes for disambiguation), joins to `downloads` and `entries` to return the entry title, download details, and a link.
|
||||||
|
|
||||||
|
**Client-side MD5:** Web Crypto doesn't support MD5. Include a small pure-JS MD5 utility (~80 lines, well-known algorithm). No new npm dependencies.
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### 1. `src/utils/md5.ts` — Pure-JS MD5 for browser use
|
||||||
|
- Exports `async function computeMd5(file: File): Promise<string>`
|
||||||
|
- Reads file as ArrayBuffer, computes MD5, returns hex string
|
||||||
|
- Standard MD5 algorithm implementation, typed for TypeScript
|
||||||
|
|
||||||
|
### 2. `src/app/zxdb/actions.ts` — Server Action
|
||||||
|
- `'use server'` directive
|
||||||
|
- `identifyTape(md5: string, sizeBytes: number)`
|
||||||
|
- Queries `software_hashes` JOIN `downloads` JOIN `entries` by MD5
|
||||||
|
- If multiple matches and size_bytes narrows it, filter further
|
||||||
|
- Returns array of `{ downloadId, entryId, entryTitle, innerPath, md5, crc32, sizeBytes }`
|
||||||
|
|
||||||
|
### 3. `src/app/zxdb/TapeIdentifier.tsx` — Client Component
|
||||||
|
- `'use client'`
|
||||||
|
- States: `idle` → `hashing` → `identifying` → `results` / `not-found`
|
||||||
|
- Dropzone UI:
|
||||||
|
- Dashed border card, large tape icon, "Drop a tape file to identify it"
|
||||||
|
- Lists supported formats: `.tap .tzx .pzx .csw .p .o`
|
||||||
|
- Also has a hidden `<input type="file">` with a "or choose file" link
|
||||||
|
- Drag-over highlight state
|
||||||
|
- On file drop/select:
|
||||||
|
- Validate extension against supported list
|
||||||
|
- Show spinner + "Computing hash..."
|
||||||
|
- Compute MD5 + size client-side
|
||||||
|
- Call server action `identifyTape(md5, size)`
|
||||||
|
- Show spinner + "Searching ZXDB..."
|
||||||
|
- Results view (replaces dropzone):
|
||||||
|
- Match found: entry title as link to `/zxdb/entries/{id}`, inner filename, MD5, file size
|
||||||
|
- Multiple matches: list all
|
||||||
|
- No match: "No matching tape found in ZXDB"
|
||||||
|
- "Identify another tape" button to reset
|
||||||
|
|
||||||
|
### 4. `src/app/zxdb/page.tsx` — Add TapeIdentifier section
|
||||||
|
- Insert `<TapeIdentifier />` as a new section between the hero and "Start exploring" grid
|
||||||
|
- Wrap in a card with distinct styling to make it visually prominent
|
||||||
|
|
||||||
|
### 5. `src/server/repo/zxdb.ts` — Add lookup function
|
||||||
|
- `lookupByMd5(md5: string)` — joins `software_hashes` → `downloads` → `entries`
|
||||||
|
- Returns download_id, entry_id, entry title, inner_path, hash details
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Visit http://localhost:4000/zxdb
|
||||||
|
- Dropzone should be visible and prominent between hero and navigation grid
|
||||||
|
- Drop a known .tap/.tzx file → should show the identified entry with a link
|
||||||
|
- Drop an unknown file → should show "No matching tape found"
|
||||||
|
- Click "Identify another tape" → resets to dropzone
|
||||||
|
- Check file never leaves browser (Network tab: only the server action call with md5 + size)
|
||||||
|
- Verify non-supported extensions are rejected with helpful message
|
||||||
137
docs/plans/zxdb-missing-features.md
Normal file
137
docs/plans/zxdb-missing-features.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# ZXDB Explorer — Missing Features & Gaps
|
||||||
|
|
||||||
|
Audit of the `/zxdb` pages against the ZXDB schema and existing data. Everything listed below is backed by tables already present in the Drizzle schema (`src/server/schema/zxdb.ts`) but not yet surfaced in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Coverage
|
||||||
|
|
||||||
|
| Section | List page | Detail page | Facets/Filters |
|
||||||
|
|----------------|-----------|-------------|---------------------------------|
|
||||||
|
| Entries | Search | Full detail | genre, language, machinetype |
|
||||||
|
| Releases | Search | Downloads, scraps, files, magazine refs | — |
|
||||||
|
| Labels | Search | Authored/published entries, permissions, licenses | — |
|
||||||
|
| Magazines | Search | Issues list | — |
|
||||||
|
| Issues | via magazine | Magazine refs (reviews/references) | — |
|
||||||
|
| Genres | List | Entries by genre | — |
|
||||||
|
| Languages | List | Entries by language | — |
|
||||||
|
| Machine Types | List | Entries by type | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Top-Level Browse Pages
|
||||||
|
|
||||||
|
### 1. Countries
|
||||||
|
- **Tables:** `countries`, `labels.country_id`
|
||||||
|
- **Value:** Browse by country ("all software from Spain", "UK publishers").
|
||||||
|
|
||||||
|
### 2. Tools
|
||||||
|
- **Tables:** `tools`, `tooltypes`
|
||||||
|
- **Value:** Utilities, emulators, and development tools catalogued in ZXDB.
|
||||||
|
|
||||||
|
### 3. Features
|
||||||
|
- **Tables:** `features`
|
||||||
|
- **Value:** Hardware/software features (Multiface, Kempston joystick, etc.).
|
||||||
|
|
||||||
|
### 4. Topics
|
||||||
|
- **Tables:** `topics`, `topictypes`
|
||||||
|
- **Value:** Editorial/thematic groupings used by magazines.
|
||||||
|
|
||||||
|
### 5. Tags / Collections
|
||||||
|
- **Tables:** `tags`, `tagtypes`, `members`
|
||||||
|
- **Value:** Tags are shown per-entry but there is no top-level "browse by tag" page (e.g. all CSSCGC entries, compilations).
|
||||||
|
|
||||||
|
### 6. Licenses
|
||||||
|
- **Tables:** `licenses`, `licensetypes`, `relatedlicenses`, `licensors`
|
||||||
|
- **Value:** Shown per-entry detail but no "browse all licenses" hub (e.g. all games based on a Marvel license).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Cross-Links & Facets on Existing Pages
|
||||||
|
|
||||||
|
### 7. Magazine reviews on Entry detail
|
||||||
|
- Release detail shows magazine refs, but entry detail does **not** aggregate them.
|
||||||
|
- A user viewing an entry cannot see "reviewed in Crash #42, p.34" without drilling into each release.
|
||||||
|
|
||||||
|
### 8. Year / date filter on Entries
|
||||||
|
- ZXDB has `release_year` on releases. No year facet on the entries explorer.
|
||||||
|
- Users cannot browse "all games from 1985".
|
||||||
|
|
||||||
|
### 9. Availability type filter on Entries
|
||||||
|
- `availabletypes` API route exists but is not a facet on the entries explorer.
|
||||||
|
- Would allow filtering by "Never released", "MIA", etc.
|
||||||
|
|
||||||
|
### 10. Max players filter on Entries
|
||||||
|
- `entries.max_players` exists but is not filterable.
|
||||||
|
- Would enable "all multiplayer games".
|
||||||
|
|
||||||
|
### 11. Label type filter on Labels page
|
||||||
|
- `labeltypes` table exists and `roletypes` API is served.
|
||||||
|
- Cannot filter the labels list by type (person / company / team / magazine).
|
||||||
|
|
||||||
|
### 12. Country filter on Labels page
|
||||||
|
- Labels have `country_id` but no filter on the list page.
|
||||||
|
|
||||||
|
### 13. Country / language filter on Magazines page
|
||||||
|
- Magazine list has search but no country or language filter chips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Data on Detail Pages
|
||||||
|
|
||||||
|
### 14. Entry detail: magazine reviews section
|
||||||
|
- `search_by_magrefs` is used in release detail but entry detail does not aggregate magazine references across all releases.
|
||||||
|
- Same issue as #7 — the entry page should show a combined reviews/references panel.
|
||||||
|
|
||||||
|
### 15. Label detail: country display
|
||||||
|
- Labels have `country_id` / `country2_id` but the detail page does not show them.
|
||||||
|
|
||||||
|
### 16. Label detail: Wikipedia / website links
|
||||||
|
- `labels.link_wikipedia` and `labels.link_site` exist but are not displayed on the label detail page.
|
||||||
|
|
||||||
|
### 17. Entry detail: related entries via same license
|
||||||
|
- Licenses are shown per-entry but there is no click-through to "other games with this license".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entirely Unsurfaced Datasets
|
||||||
|
|
||||||
|
### 18. NVGs
|
||||||
|
- **Table:** `nvgs`
|
||||||
|
- Historical download archive metadata. Not exposed anywhere.
|
||||||
|
|
||||||
|
### 19. SPEX entries / authors
|
||||||
|
- **Tables:** `spex_entries`, `spex_authors`
|
||||||
|
- No UI.
|
||||||
|
|
||||||
|
### 20. Awards
|
||||||
|
- **Table:** `zxsr_awards`, referenced by `magrefs.award_id`
|
||||||
|
- No awards browsing or display.
|
||||||
|
|
||||||
|
### 21. Review text
|
||||||
|
- **Table:** `zxsr_reviews` (`intro_text`, `review_text`)
|
||||||
|
- Magazine refs link to reviews by ID but the actual review text is never rendered.
|
||||||
|
|
||||||
|
### 22. Articles
|
||||||
|
- **Tables:** `articles`, `articletypes`
|
||||||
|
- No articles browsing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation / UX Gaps
|
||||||
|
|
||||||
|
### 23. No discovery mechanism
|
||||||
|
- No "random entry", "on this day", or "featured" section. Common for large historic databases.
|
||||||
|
|
||||||
|
### 24. No stats / dashboard
|
||||||
|
- No summary counts ("ZXDB has X entries, Y labels, Z magazines"). Would anchor the landing page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Priority
|
||||||
|
|
||||||
|
| Priority | Items | Rationale |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| High | 7/14 (magazine refs on entry detail), 8 (year filter), 15-16 (label country + links) | Data exists, just not wired up. High user value. |
|
||||||
|
| Medium | 1 (countries), 5 (tags browse), 6 (licenses browse), 9 (availability filter), 24 (stats) | New pages but straightforward queries. |
|
||||||
|
| Low | 2-4 (tools/features/topics), 10-13 (additional filters), 17-22 (unsurfaced datasets), 23 (discovery) | Useful but niche or requires more design work. |
|
||||||
20
docs/registers.md
Normal file
20
docs/registers.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Register Explorer
|
||||||
|
|
||||||
|
Overview
|
||||||
|
The Register Explorer lets you browse and search Spectrum Next registers parsed from `data/nextreg.txt`. Each register page shows address, access details, bit tables, and notes.
|
||||||
|
|
||||||
|
Searching
|
||||||
|
- Use the search input to filter registers in real time.
|
||||||
|
- The query is case‑insensitive and matches a combined `search` field per register (name, address, and keywords).
|
||||||
|
|
||||||
|
Deep links (query string)
|
||||||
|
- The search box syncs with the `q` query parameter so searches are shareable.
|
||||||
|
- Example: `/registers?q=vram`
|
||||||
|
- When you open this URL, the search box is pre‑filled with `vram` and the list is filtered immediately.
|
||||||
|
- Clearing the search removes `q` from the URL.
|
||||||
|
|
||||||
|
Implementation notes
|
||||||
|
- Component: `src/app/registers/RegisterBrowser.tsx`
|
||||||
|
- Uses Next.js navigation hooks: `useSearchParams`, `useRouter`, `usePathname`.
|
||||||
|
- On mount and when the URL changes, the component reads `q` and updates local state.
|
||||||
|
- On input change, the component updates state and calls `router.replace()` to keep the URL in sync without scrolling.
|
||||||
14
drizzle.config.ts
Normal file
14
drizzle.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
|
// This configuration is optional at the moment (no migrations run here),
|
||||||
|
// but kept for future schema generation if needed.
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: "./src/server/schema/**/*.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "mysql",
|
||||||
|
dbCredentials: {
|
||||||
|
// Read from env at runtime when using drizzle-kit
|
||||||
|
url: process.env.ZXDB_URL!,
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
41
example.env
Normal file
41
example.env
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 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_REMOTE_FILEPATH
|
||||||
|
ZXDB_REMOTE_FILEPATH=https://zxdbfiles.com/
|
||||||
|
|
||||||
|
# When file_link starts with /public, it will be fetched from WOS_REMOTE_FILEPATH
|
||||||
|
# Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
|
||||||
|
WOS_REMOTE_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 mirror filesystem paths for downloads.
|
||||||
|
# Enabling these (and verifying existence) will show "Local Mirror" links.
|
||||||
|
# See docs/ZXDB.md for how prefixes are stripped and joined to these paths.
|
||||||
|
# ZXDB_LOCAL_FILEPATH=/path/to/local/zxdb/mirror
|
||||||
|
# WOS_LOCAL_FILEPATH=/path/to/local/wos/mirror
|
||||||
|
|
||||||
|
# Optional: Path prefixes to strip from database links before local matching.
|
||||||
|
# ZXDB_FILE_PREFIX=/zxdb/sinclair/
|
||||||
|
# WOS_FILE_PREFIX=/pub/sinclair/
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,31 +1,42 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
|
"update:hashes": "node bin/update-software-hashes.mjs",
|
||||||
|
"export:hashes": "node bin/update-software-hashes.mjs --export-only",
|
||||||
"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";
|
||||||
71
src/app/api/zxdb/download/route.ts
Normal file
71
src/app/api/zxdb/download/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const source = searchParams.get("source");
|
||||||
|
const filePath = searchParams.get("path");
|
||||||
|
|
||||||
|
if (!source || !filePath) {
|
||||||
|
return new NextResponse("Missing source or path", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseDir: string | undefined;
|
||||||
|
if (source === "zxdb") {
|
||||||
|
baseDir = env.ZXDB_LOCAL_FILEPATH;
|
||||||
|
} else if (source === "wos") {
|
||||||
|
baseDir = env.WOS_LOCAL_FILEPATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseDir) {
|
||||||
|
return new NextResponse("Invalid source or mirroring not enabled", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Ensure path doesn't escape baseDir
|
||||||
|
const absolutePath = path.normalize(path.join(baseDir, filePath));
|
||||||
|
if (!absolutePath.startsWith(path.normalize(baseDir))) {
|
||||||
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
return new NextResponse("File not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(absolutePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return new NextResponse("Not a file", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(absolutePath);
|
||||||
|
const fileName = path.basename(absolutePath);
|
||||||
|
const ext = path.extname(fileName).toLowerCase();
|
||||||
|
|
||||||
|
// Determine Content-Type
|
||||||
|
let contentType = "application/octet-stream";
|
||||||
|
if (ext === ".txt" || ext === ".nfo") {
|
||||||
|
contentType = "text/plain; charset=utf-8";
|
||||||
|
} else if (ext === ".png") {
|
||||||
|
contentType = "image/png";
|
||||||
|
} else if (ext === ".jpg" || ext === ".jpeg") {
|
||||||
|
contentType = "image/jpeg";
|
||||||
|
} else if (ext === ".gif") {
|
||||||
|
contentType = "image/gif";
|
||||||
|
} else if (ext === ".pdf") {
|
||||||
|
contentType = "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isView = searchParams.get("view") === "1";
|
||||||
|
const disposition = isView ? "inline" : "attachment";
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": `${disposition}; filename="${fileName}"`,
|
||||||
|
"Content-Length": stat.size.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
58
src/app/api/zxdb/releases/search/route.ts
Normal file
58
src/app/api/zxdb/releases/search/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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.string().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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseIdList(value: string | undefined) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const ids = value
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 dMachinetypeId = parseIdList(parsed.data.dMachinetypeId);
|
||||||
|
const data = await searchReleases({ ...parsed.data, dMachinetypeId });
|
||||||
|
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";
|
||||||
63
src/app/api/zxdb/search/route.ts
Normal file
63
src/app/api/zxdb/search/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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.string().optional(),
|
||||||
|
year: z.coerce.number().int().optional(),
|
||||||
|
sort: z.enum(["title", "id_desc"]).optional(),
|
||||||
|
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
|
||||||
|
facets: z.coerce.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseIdList(value: string | undefined) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const ids = value
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
year: searchParams.get("year") ?? 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 machinetypeId = parseIdList(parsed.data.machinetypeId);
|
||||||
|
const searchParamsParsed = { ...parsed.data, machinetypeId };
|
||||||
|
const data = await searchEntries(searchParamsParsed);
|
||||||
|
const body = parsed.data.facets
|
||||||
|
? { ...data, facets: await getEntryFacets(searchParamsParsed) }
|
||||||
|
: data;
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Node.js runtime (required for mysql2)
|
||||||
|
export const runtime = "nodejs";
|
||||||
10
src/app/api/zxdb/sourcetypes/route.ts
Normal file
10
src/app/api/zxdb/sourcetypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { listSourcetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await listSourcetypes();
|
||||||
|
return new Response(JSON.stringify({ items }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
@@ -5,7 +5,7 @@ import NavbarClient from "@/components/Navbar";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Spectrum Next Explorer",
|
title: "Spectrum Next Explorer",
|
||||||
description: "A platform for exploring the Spectrum Next hardware",
|
description: "A platform for exploring the Spectrum Next ecosystem",
|
||||||
robots: { index: true, follow: true },
|
robots: { index: true, follow: true },
|
||||||
formatDetection: { email: false, address: false, telephone: false },
|
formatDetection: { email: false, address: false, telephone: false },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,45 @@
|
|||||||
import styles from "./page.module.css";
|
import Link from "next/link";
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className="container-fluid py-4">
|
||||||
<main className={styles.main}>
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body d-flex flex-column gap-3">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-collection" style={{ fontSize: 40 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h1 className="h3 mb-1">ZXDB Explorer</h1>
|
||||||
|
<p className="text-secondary mb-0">Search entries, releases, magazines, and labels.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-primary" href="/zxdb">Open ZXDB</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">Built for deep linking and fast filters.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/registers">
|
<div className="col-lg-6">
|
||||||
Register Explorer →
|
<div className="card h-100 shadow-sm">
|
||||||
</Link>
|
<div className="card-body d-flex flex-column gap-3">
|
||||||
</main>
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-cpu" style={{ fontSize: 40 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h2 className="h3 mb-1">NextReg Explorer</h2>
|
||||||
|
<p className="text-secondary mb-0">Browse Spectrum Next registers and bitfields.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-primary" href="/registers">Open registers</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">Parsed locally from official NextReg definitions.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Register, RegisterAccess, Note } from '@/utils/register_parser';
|
import { Register, RegisterAccess, Note } from "@/utils/register_parser";
|
||||||
import { Form, Container, Row, Table, OverlayTrigger, Tooltip } from 'react-bootstrap';
|
import { Form, Row, Table, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
import RegisterDetail from "@/app/registers/RegisterDetail";
|
import RegisterDetail from "@/app/registers/RegisterDetail";
|
||||||
|
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
|
||||||
|
import FilterSidebar from "@/components/explorer/FilterSidebar";
|
||||||
|
|
||||||
interface RegisterBrowserProps {
|
interface RegisterBrowserProps {
|
||||||
registers: Register[];
|
registers: Register[];
|
||||||
@@ -73,7 +75,7 @@ export function renderAccess(access: RegisterAccess, extraNotes: Note[] = []) {
|
|||||||
* @returns A React component that allows users to browse and search registers.
|
* @returns A React component that allows users to browse and search registers.
|
||||||
*/
|
*/
|
||||||
export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
|
export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -102,30 +104,42 @@ export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
|
|||||||
router.replace(url, { scroll: false });
|
router.replace(url, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredRegisters = registers.filter(register =>
|
const filteredRegisters = useMemo(() => (
|
||||||
register.search.includes(searchTerm.toLowerCase())
|
registers.filter((register) => register.search.includes(searchTerm.toLowerCase()))
|
||||||
);
|
), [registers, searchTerm]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container fluid>
|
<ExplorerLayout
|
||||||
<Form.Group className="mb-3">
|
title="NextReg Explorer"
|
||||||
<Form.Control
|
subtitle={`${filteredRegisters.length.toLocaleString()} results`}
|
||||||
type="text"
|
chips={searchTerm ? [`q: ${searchTerm}`] : []}
|
||||||
placeholder="Search registers..."
|
onClearChips={() => {
|
||||||
value={searchTerm}
|
setSearchTerm("");
|
||||||
onChange={e => {
|
updateQueryString("");
|
||||||
const v = e.target.value;
|
}}
|
||||||
setSearchTerm(v);
|
sidebar={(
|
||||||
updateQueryString(v);
|
<FilterSidebar>
|
||||||
}}
|
<Form.Group>
|
||||||
/>
|
<Form.Label className="form-label small text-secondary">Search</Form.Label>
|
||||||
</Form.Group>
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="Search registers..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setSearchTerm(v);
|
||||||
|
updateQueryString(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</FilterSidebar>
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Row>
|
<Row>
|
||||||
{filteredRegisters.map(register => (
|
{filteredRegisters.map((register) => (
|
||||||
<RegisterDetail key={register.hex_address} register={register} />
|
<RegisterDetail key={register.hex_address} register={register} />
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
</Container>
|
</ExplorerLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export default async function RegistersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid py-4">
|
<div className="container-fluid py-4">
|
||||||
<h1 className="mb-4">NextReg Explorer</h1>
|
|
||||||
<RegisterBrowser registers={registers} />
|
<RegisterBrowser registers={registers} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
233
src/app/zxdb/TapeIdentifier.tsx
Normal file
233
src/app/zxdb/TapeIdentifier.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { computeMd5 } from "@/utils/md5";
|
||||||
|
import { identifyTape } from "./actions";
|
||||||
|
import type { TapeMatch } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "hashing" }
|
||||||
|
| { kind: "identifying" }
|
||||||
|
| { kind: "results"; matches: TapeMatch[]; fileName: string }
|
||||||
|
| { kind: "not-found"; fileName: string }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TapeIdentifier() {
|
||||||
|
const [state, setState] = useState<State>({ kind: "idle" });
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const processFile = useCallback(async (file: File) => {
|
||||||
|
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
|
||||||
|
if (!SUPPORTED_EXTS.includes(ext)) {
|
||||||
|
setState({
|
||||||
|
kind: "error",
|
||||||
|
message: `Unsupported file type "${ext}". Supported: ${SUPPORTED_EXTS.join(", ")}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ kind: "hashing" });
|
||||||
|
try {
|
||||||
|
const md5 = await computeMd5(file);
|
||||||
|
setState({ kind: "identifying" });
|
||||||
|
const matches = await identifyTape(md5, file.size);
|
||||||
|
if (matches.length > 0) {
|
||||||
|
setState({ kind: "results", matches, fileName: file.name });
|
||||||
|
} else {
|
||||||
|
setState({ kind: "not-found", fileName: file.name });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState({ kind: "error", message: "Something went wrong. Please try again." });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) processFile(file);
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileInput = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) processFile(file);
|
||||||
|
// Reset so re-selecting the same file triggers change
|
||||||
|
e.target.value = "";
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({ kind: "idle" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Dropzone view (idle, hashing, identifying, error)
|
||||||
|
if (state.kind === "results" || state.kind === "not-found") {
|
||||||
|
return (
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
|
||||||
|
Tape Identifier
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{state.kind === "results" ? (
|
||||||
|
<>
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
<strong>{state.fileName}</strong> matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:
|
||||||
|
</p>
|
||||||
|
{state.matches.map((m) => (
|
||||||
|
<div key={m.downloadId} className="card border mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<h6 className="card-title mb-0">
|
||||||
|
<Link href={`/zxdb/entries/${m.entryId}`} className="text-decoration-none">
|
||||||
|
{m.entryTitle}
|
||||||
|
</Link>
|
||||||
|
</h6>
|
||||||
|
{m.releaseYear && (
|
||||||
|
<span className="badge text-bg-secondary ms-2">{m.releaseYear}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(m.authors.length > 0 || m.genre || m.machinetype) && (
|
||||||
|
<div className="d-flex flex-wrap gap-2 mb-2 small text-secondary">
|
||||||
|
{m.authors.length > 0 && (
|
||||||
|
<span><span className="bi bi-person me-1" aria-hidden />{m.authors.join(", ")}</span>
|
||||||
|
)}
|
||||||
|
{m.genre && (
|
||||||
|
<span><span className="bi bi-tag me-1" aria-hidden />{m.genre}</span>
|
||||||
|
)}
|
||||||
|
{m.machinetype && (
|
||||||
|
<span><span className="bi bi-cpu me-1" aria-hidden />{m.machinetype}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="table table-sm table-borderless mb-2 small" style={{ maxWidth: 500 }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0" style={{ width: 90 }}>File</td>
|
||||||
|
<td className="font-monospace">{m.innerPath}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0">Size</td>
|
||||||
|
<td>{formatBytes(m.sizeBytes)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0">MD5</td>
|
||||||
|
<td className="font-monospace">{m.md5}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0">CRC32</td>
|
||||||
|
<td className="font-monospace">{m.crc32}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/zxdb/entries/${m.entryId}`}
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
>
|
||||||
|
View entry <span className="bi bi-arrow-right ms-1" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
No matching tape found in ZXDB for <strong>{state.fileName}</strong>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn-outline-primary btn-sm" onClick={reset}>
|
||||||
|
Identify another tape
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProcessing = state.kind === "hashing" || state.kind === "identifying";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
|
||||||
|
Tape Identifier
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-3 p-4 text-center ${dragOver ? "bg-primary bg-opacity-10 border-primary" : "border-secondary border-opacity-25"}`}
|
||||||
|
style={{
|
||||||
|
border: "2px dashed",
|
||||||
|
cursor: isProcessing ? "wait" : "pointer",
|
||||||
|
transition: "background-color 0.15s, border-color 0.15s",
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isProcessing) setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={isProcessing ? (e) => e.preventDefault() : handleDrop}
|
||||||
|
onClick={isProcessing ? undefined : () => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="spinner-border spinner-border-sm text-primary me-2" role="status" />
|
||||||
|
<span className="text-secondary">
|
||||||
|
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="bi bi-cloud-arrow-up" style={{ fontSize: 32, opacity: 0.5 }} aria-hidden />
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-secondary">
|
||||||
|
Drop a tape file to identify it
|
||||||
|
</p>
|
||||||
|
<p className="mb-0 small text-secondary">
|
||||||
|
{SUPPORTED_EXTS.join(" ")} — or{" "}
|
||||||
|
<span className="text-primary" style={{ textDecoration: "underline", cursor: "pointer" }}>
|
||||||
|
choose file
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={SUPPORTED_EXTS.join(",")}
|
||||||
|
className="d-none"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.kind === "error" && (
|
||||||
|
<div className="alert alert-warning mt-3 mb-0 py-2 small">
|
||||||
|
{state.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/app/zxdb/ZxdbExplorer.tsx
Normal file
271
src/app/zxdb/ZxdbExplorer.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"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 [year, setYear] = useState<string>("");
|
||||||
|
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 (year !== "") params.set("year", year);
|
||||||
|
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 === "" &&
|
||||||
|
year === "" &&
|
||||||
|
sort === "id_desc"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchData(q, page);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, genreId, languageId, machinetypeId, year, 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">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
placeholder="Year"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(e.target.value)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/zxdb/actions.ts
Normal file
22
src/app/zxdb/actions.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { lookupByMd5, type TapeMatch } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function identifyTape(
|
||||||
|
md5: string,
|
||||||
|
sizeBytes: number
|
||||||
|
): Promise<TapeMatch[]> {
|
||||||
|
// Validate input shape
|
||||||
|
if (!/^[0-9a-f]{32}$/i.test(md5)) return [];
|
||||||
|
if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return [];
|
||||||
|
|
||||||
|
const matches = await lookupByMd5(md5);
|
||||||
|
|
||||||
|
// If multiple matches and size can disambiguate, filter by size
|
||||||
|
if (matches.length > 1) {
|
||||||
|
const bySz = matches.filter((m) => m.sizeBytes === sizeBytes);
|
||||||
|
if (bySz.length > 0) return bySz;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
471
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
471
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
"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";
|
||||||
|
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
|
||||||
|
import FilterSidebar from "@/components/explorer/FilterSidebar";
|
||||||
|
import MultiSelectChips from "@/components/explorer/MultiSelectChips";
|
||||||
|
|
||||||
|
const preferredMachineIds = [27, 26, 8, 9];
|
||||||
|
|
||||||
|
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;
|
||||||
|
sort: "title" | "id_desc";
|
||||||
|
scope?: SearchScope;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const parseMachineIds = (value?: string) => {
|
||||||
|
if (!value) return preferredMachineIds.slice();
|
||||||
|
const ids = value
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : preferredMachineIds.slice();
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const [q, setQ] = useState(initialUrlState?.q ?? "");
|
||||||
|
const [appliedQ, setAppliedQ] = 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 [machinetypeIds, setMachinetypeIds] = useState<number[]>(parseMachineIds(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 preferredMachineNames = useMemo(() => {
|
||||||
|
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`);
|
||||||
|
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
|
||||||
|
}, [machines]);
|
||||||
|
const orderedMachines = useMemo(() => {
|
||||||
|
const seen = new Set(preferredMachineIds);
|
||||||
|
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[];
|
||||||
|
const rest = machines.filter((m) => !seen.has(m.id));
|
||||||
|
return [...preferred, ...rest];
|
||||||
|
}, [machines]);
|
||||||
|
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
|
||||||
|
|
||||||
|
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 (appliedQ) chips.push(`q: ${appliedQ}`);
|
||||||
|
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 (machinetypeIds.length > 0) {
|
||||||
|
const names = machinetypeIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
|
||||||
|
chips.push(`machine: ${names.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (scope === "title_aliases") chips.push("scope: titles + aliases");
|
||||||
|
if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins");
|
||||||
|
return chips;
|
||||||
|
}, [appliedQ, genreId, languageId, machinetypeIds, scope, genres, languages, machines]);
|
||||||
|
|
||||||
|
function updateUrl(nextPage = page) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appliedQ) params.set("q", appliedQ);
|
||||||
|
params.set("page", String(nextPage));
|
||||||
|
if (genreId !== "") params.set("genreId", String(genreId));
|
||||||
|
if (languageId !== "") params.set("languageId", String(languageId));
|
||||||
|
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
|
||||||
|
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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
|
||||||
|
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 ?? "") === appliedQ &&
|
||||||
|
(initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) &&
|
||||||
|
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
|
||||||
|
parseMachineIds(initialUrlState?.machinetypeId).join(",") === machinetypeIds.join(",") &&
|
||||||
|
sort === (initialUrlState?.sort ?? "id_desc") &&
|
||||||
|
(initialUrlState?.scope ?? "title") === scope
|
||||||
|
) {
|
||||||
|
updateUrl(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateUrl(page);
|
||||||
|
fetchData(appliedQ, page, true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
setAppliedQ(q);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
setQ("");
|
||||||
|
setAppliedQ("");
|
||||||
|
setGenreId("");
|
||||||
|
setLanguageId("");
|
||||||
|
setMachinetypeIds(preferredMachineIds.slice());
|
||||||
|
setSort("id_desc");
|
||||||
|
setScope("title");
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appliedQ) params.set("q", appliedQ);
|
||||||
|
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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
|
return `/zxdb/entries?${params.toString()}`;
|
||||||
|
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
|
||||||
|
|
||||||
|
const nextHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appliedQ) params.set("q", appliedQ);
|
||||||
|
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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
|
return `/zxdb/entries?${params.toString()}`;
|
||||||
|
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Entries" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExplorerLayout
|
||||||
|
title="Entries"
|
||||||
|
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
|
||||||
|
chips={activeFilters}
|
||||||
|
onClearChips={resetFilters}
|
||||||
|
sidebar={(
|
||||||
|
<FilterSidebar>
|
||||||
|
<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>
|
||||||
|
<MultiSelectChips
|
||||||
|
options={machineOptions}
|
||||||
|
selected={machinetypeIds}
|
||||||
|
onToggle={(id) => {
|
||||||
|
setMachinetypeIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
const order = machineOptions.map((item) => item.id);
|
||||||
|
return order.filter((value) => next.has(value));
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
|
||||||
|
</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>
|
||||||
|
</FilterSidebar>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</ExplorerLayout>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
927
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
927
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
import FileViewer from "@/components/FileViewer";
|
||||||
|
|
||||||
|
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;
|
||||||
|
localLink?: string | null;
|
||||||
|
}[];
|
||||||
|
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;
|
||||||
|
localLink?: string | null;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
// Additional relationships
|
||||||
|
aliases?: { releaseSeq: number; languageId: string; title: string }[];
|
||||||
|
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
|
||||||
|
magazineRefs?: {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
|
||||||
|
const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
const groupedDownloads = useMemo(() => {
|
||||||
|
if (!data?.downloadsFlat) return [];
|
||||||
|
const groups = new Map<string, EntryDetailData["downloadsFlat"]>();
|
||||||
|
for (const d of data.downloadsFlat) {
|
||||||
|
const type = d.type.name;
|
||||||
|
const arr = groups.get(type) ?? [];
|
||||||
|
arr.push(d);
|
||||||
|
groups.set(type, arr);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [data?.downloadsFlat]);
|
||||||
|
|
||||||
|
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 mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Magazine References</h5>
|
||||||
|
{(!data.magazineRefs || data.magazineRefs.length === 0) && <div className="text-secondary">No magazine references recorded</div>}
|
||||||
|
{data.magazineRefs && data.magazineRefs.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Magazine</th>
|
||||||
|
<th style={{ width: 140 }}>Issue</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th style={{ width: 120 }}>Page</th>
|
||||||
|
<th>Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.magazineRefs.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>
|
||||||
|
{m.magazineId ? (
|
||||||
|
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{m.magazineName}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/issues/${m.issueId}`}>
|
||||||
|
{m.issue.dateYear ? `${m.issue.dateYear} ` : ""}
|
||||||
|
{m.issue.number ? `#${m.issue.number}` : ""}
|
||||||
|
{m.issue.special ? ` (${m.issue.special})` : ""}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{m.referencetypeName}</td>
|
||||||
|
<td>{m.page > 0 ? m.page : "-"}</td>
|
||||||
|
<td>{m.scoreGroup || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<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>
|
||||||
|
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
||||||
|
{groupedDownloads.map(([type, items]) => (
|
||||||
|
<div key={type} className="mb-4">
|
||||||
|
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Link</th>
|
||||||
|
<th style={{ width: 100 }} className="text-end">Size</th>
|
||||||
|
<th style={{ width: 180 }}>MD5</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Comments</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items?.map((d) => {
|
||||||
|
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
|
||||||
|
const fileName = d.link.split("/").pop() || "file";
|
||||||
|
const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
|
||||||
|
return (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{isHttp ? (
|
||||||
|
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-break small">{d.link}</span>
|
||||||
|
)}
|
||||||
|
{canPreview && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-outline-info py-0 px-1"
|
||||||
|
style={{ fontSize: "0.6rem" }}
|
||||||
|
onClick={() => setViewer({ url: d.localLink!, title: fileName })}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{d.localLink && (
|
||||||
|
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
|
||||||
|
Local Mirror
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||||
|
<td><code style={{ fontSize: "0.75rem" }}>{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 className="small">{d.comments ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
{viewer && (
|
||||||
|
<FileViewer
|
||||||
|
url={viewer.url}
|
||||||
|
title={viewer.title}
|
||||||
|
onClose={() => setViewer(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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} />;
|
||||||
|
}
|
||||||
69
src/app/zxdb/entries/page.tsx
Normal file
69
src/app/zxdb/entries/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
function parseIdList(value: string | string[] | undefined) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const raw = Array.isArray(value) ? value.join(",") : value;
|
||||||
|
const ids = raw
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 preferredMachineIds = [27, 26, 8, 9];
|
||||||
|
const machinetypeIds = parseIdList(sp.machinetypeId) ?? preferredMachineIds;
|
||||||
|
const machinetypeId = machinetypeIds.join(",");
|
||||||
|
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: machinetypeIds,
|
||||||
|
}),
|
||||||
|
listGenres(),
|
||||||
|
listLanguages(),
|
||||||
|
listMachinetypes(),
|
||||||
|
getEntryFacets({
|
||||||
|
q,
|
||||||
|
sort,
|
||||||
|
scope,
|
||||||
|
genreId: genreId ? Number(genreId) : undefined,
|
||||||
|
languageId: languageId || undefined,
|
||||||
|
machinetypeId: machinetypeIds,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
243
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"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;
|
||||||
|
countryId: string | null;
|
||||||
|
countryName: string | null;
|
||||||
|
country2Id: string | null;
|
||||||
|
country2Name: string | null;
|
||||||
|
linkWikipedia: string | null;
|
||||||
|
linkSite: 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>
|
||||||
|
{(initial.label.countryId || initial.label.linkWikipedia || initial.label.linkSite) && (
|
||||||
|
<div className="mt-2 d-flex gap-3 flex-wrap align-items-center">
|
||||||
|
{initial.label.countryId && (
|
||||||
|
<span className="text-secondary small">
|
||||||
|
Country: <strong>{initial.label.countryName || initial.label.countryId}</strong>
|
||||||
|
{initial.label.country2Id && (
|
||||||
|
<> / <strong>{initial.label.country2Name || initial.label.country2Id}</strong></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{initial.label.linkWikipedia && (
|
||||||
|
<a href={initial.label.linkWikipedia} target="_blank" rel="noreferrer" className="btn btn-sm btn-outline-secondary py-0">Wikipedia</a>
|
||||||
|
)}
|
||||||
|
{initial.label.linkSite && (
|
||||||
|
<a href={initial.label.linkSite} target="_blank" rel="noreferrer" className="btn btn-sm btn-outline-secondary py-0">Website</a>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/app/zxdb/page.tsx
Normal file
173
src/app/zxdb/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import TapeIdentifier from "./TapeIdentifier";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "ZXDB Explorer",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-column gap-4">
|
||||||
|
<section
|
||||||
|
className="rounded-4 p-4 p-lg-5 shadow-sm"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(13,110,253,0.08), rgba(25,135,84,0.08))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row align-items-center g-4">
|
||||||
|
<div className="col-lg-7">
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span className="badge text-bg-dark">ZXDB</span>
|
||||||
|
<span className="badge text-bg-secondary">Explorer</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="display-6 mb-3">ZXDB Explorer</h1>
|
||||||
|
<p className="lead text-secondary mb-4">
|
||||||
|
Trace Spectrum-era software across entries, releases, magazines, and labels with deep links and fast filters.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/entries">Browse entries</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/releases">Latest releases</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">Magazine issues</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">People & labels</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-5">
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title mb-3">Jump straight in</h5>
|
||||||
|
<form className="d-flex flex-column gap-2" method="get" action="/zxdb/entries">
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search entries</label>
|
||||||
|
<input className="form-control" name="q" placeholder="Try: manic, doom, renegade..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Scope</label>
|
||||||
|
<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>
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="row g-3">
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<TapeIdentifier />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4 d-flex align-items-center">
|
||||||
|
<p className="text-secondary small mb-0">
|
||||||
|
Drop a <code>.tap</code>, <code>.tzx</code>, or other tape file to identify it against 32,000+ ZXDB entries.
|
||||||
|
The file stays in your browser — only its hash is sent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="h4 mb-0">Start exploring</h2>
|
||||||
|
<span className="text-secondary small">Pick a path to dive deeper</span>
|
||||||
|
</div>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<Link href="/zxdb/entries" className="text-decoration-none">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-collection" style={{ fontSize: 28 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">Entries</h5>
|
||||||
|
<div className="card-text text-secondary">Search + filter titles</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<Link href="/zxdb/releases" className="text-decoration-none">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">Releases</h5>
|
||||||
|
<div className="card-text text-secondary">Downloads + media</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<Link href="/zxdb/magazines" className="text-decoration-none">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-journal-text" style={{ fontSize: 28 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">Magazines</h5>
|
||||||
|
<div className="card-text text-secondary">Issues + references</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<Link href="/zxdb/labels" className="text-decoration-none">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-people" style={{ fontSize: 28 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">Labels</h5>
|
||||||
|
<div className="card-text text-secondary">People + publishers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="row g-3">
|
||||||
|
<div className="col-lg-7">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h5">Explore by category</h3>
|
||||||
|
<p className="text-secondary mb-3">Jump to curated lists and filter results from there.</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-5">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h5">How to use this</h3>
|
||||||
|
<ol className="mb-0 text-secondary small">
|
||||||
|
<li>Search by title or aliases in Entries.</li>
|
||||||
|
<li>Open a release to see downloads, scraps, and places.</li>
|
||||||
|
<li>Use magazines to find original reviews and references.</li>
|
||||||
|
<li>Follow labels to discover related work.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
472
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
472
src/app/zxdb/releases/ReleasesExplorer.tsx
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, 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";
|
||||||
|
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
|
||||||
|
import FilterSidebar from "@/components/explorer/FilterSidebar";
|
||||||
|
import MultiSelectChips from "@/components/explorer/MultiSelectChips";
|
||||||
|
|
||||||
|
const preferredMachineIds = [27, 26, 8, 9];
|
||||||
|
|
||||||
|
function parseMachineIds(value?: string) {
|
||||||
|
if (!value) return preferredMachineIds.slice();
|
||||||
|
const ids = value
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : preferredMachineIds.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [appliedQ, setAppliedQ] = 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 [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(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 preferredMachineNames = useMemo(() => {
|
||||||
|
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`);
|
||||||
|
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
|
||||||
|
}, [machines]);
|
||||||
|
const orderedMachines = useMemo(() => {
|
||||||
|
const seen = new Set(preferredMachineIds);
|
||||||
|
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[];
|
||||||
|
const rest = machines.filter((m) => !seen.has(m.id));
|
||||||
|
return [...preferred, ...rest];
|
||||||
|
}, [machines]);
|
||||||
|
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
|
const updateUrl = useCallback((nextPage = page) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appliedQ) params.set("q", appliedQ);
|
||||||
|
params.set("page", String(nextPage));
|
||||||
|
if (year) params.set("year", year);
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
|
if (dLanguageId) params.set("dLanguageId", dLanguageId);
|
||||||
|
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
|
||||||
|
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);
|
||||||
|
}, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, page, pathname, router, schemetypeId, sort, sourcetypeId, year]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (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 (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, pageSize, schemetypeId, sort, sourcetypeId, year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) {
|
||||||
|
setData(initial);
|
||||||
|
setPage(initial.page);
|
||||||
|
}
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const initialState = useMemo(() => ({
|
||||||
|
q: initialUrlState?.q ?? "",
|
||||||
|
year: initialUrlState?.year ?? "",
|
||||||
|
sort: initialUrlState?.sort ?? "year_desc",
|
||||||
|
dLanguageId: initialUrlState?.dLanguageId ?? "",
|
||||||
|
dMachinetypeId: initialUrlState?.dMachinetypeId ?? "",
|
||||||
|
filetypeId: initialUrlState?.filetypeId ?? "",
|
||||||
|
schemetypeId: initialUrlState?.schemetypeId ?? "",
|
||||||
|
sourcetypeId: initialUrlState?.sourcetypeId ?? "",
|
||||||
|
casetypeId: initialUrlState?.casetypeId ?? "",
|
||||||
|
isDemo: initialUrlState?.isDemo,
|
||||||
|
}), [initialUrlState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initialPage = initial?.page ?? 1;
|
||||||
|
if (
|
||||||
|
initial &&
|
||||||
|
page === initialPage &&
|
||||||
|
initialState.q === appliedQ &&
|
||||||
|
initialState.year === (year ?? "") &&
|
||||||
|
sort === initialState.sort &&
|
||||||
|
initialState.dLanguageId === dLanguageId &&
|
||||||
|
parseMachineIds(initialState.dMachinetypeId).join(",") === dMachinetypeIds.join(",") &&
|
||||||
|
initialState.filetypeId === filetypeId &&
|
||||||
|
initialState.schemetypeId === schemetypeId &&
|
||||||
|
initialState.sourcetypeId === sourcetypeId &&
|
||||||
|
initialState.casetypeId === casetypeId &&
|
||||||
|
(!!initialState.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(appliedQ, page);
|
||||||
|
}, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, fetchData, filetypeId, initial, initialState, initialUrlHasParams, isDemo, page, schemetypeId, sort, sourcetypeId, updateUrl, year]);
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setAppliedQ(q);
|
||||||
|
setPage(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();
|
||||||
|
}, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]);
|
||||||
|
|
||||||
|
const prevHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appliedQ) params.set("q", appliedQ);
|
||||||
|
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 (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
|
||||||
|
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()}`;
|
||||||
|
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
|
||||||
|
|
||||||
|
const nextHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appliedQ) params.set("q", appliedQ);
|
||||||
|
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 (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
|
||||||
|
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()}`;
|
||||||
|
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Releases" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExplorerLayout
|
||||||
|
title="Releases"
|
||||||
|
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
|
||||||
|
sidebar={(
|
||||||
|
<FilterSidebar>
|
||||||
|
<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>
|
||||||
|
<MultiSelectChips
|
||||||
|
options={machineOptions}
|
||||||
|
selected={dMachinetypeIds}
|
||||||
|
onToggle={(id) => {
|
||||||
|
setDMachinetypeIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
const order = machineOptions.map((item) => item.id);
|
||||||
|
return order.filter((value) => next.has(value));
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
|
||||||
|
</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>
|
||||||
|
</FilterSidebar>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</ExplorerLayout>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
611
src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx
Normal file
611
src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
import FileViewer from "@/components/FileViewer";
|
||||||
|
|
||||||
|
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;
|
||||||
|
localLink?: string | 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;
|
||||||
|
localLink?: string | 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 }) {
|
||||||
|
const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
const groupedDownloads = useMemo(() => {
|
||||||
|
if (!data?.downloads) return [];
|
||||||
|
const groups = new Map<string, ReleaseDetailData["downloads"]>();
|
||||||
|
for (const d of data.downloads) {
|
||||||
|
const type = d.type.name;
|
||||||
|
const arr = groups.get(type) ?? [];
|
||||||
|
arr.push(d);
|
||||||
|
groups.set(type, arr);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [data?.downloads]);
|
||||||
|
|
||||||
|
const groupedScraps = useMemo(() => {
|
||||||
|
if (!data?.scraps) return [];
|
||||||
|
const groups = new Map<string, ReleaseDetailData["scraps"]>();
|
||||||
|
for (const s of data.scraps) {
|
||||||
|
const type = s.type.name;
|
||||||
|
const arr = groups.get(type) ?? [];
|
||||||
|
arr.push(s);
|
||||||
|
groups.set(type, arr);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [data?.scraps]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
||||||
|
{groupedDownloads.map(([type, items]) => (
|
||||||
|
<div key={type} className="mb-4">
|
||||||
|
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Link</th>
|
||||||
|
<th style={{ width: 100 }} className="text-end">Size</th>
|
||||||
|
<th style={{ width: 180 }}>MD5</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Comments</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items?.map((d) => {
|
||||||
|
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
|
||||||
|
const fileName = d.link.split("/").pop() || "file";
|
||||||
|
const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
|
||||||
|
return (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{isHttp ? (
|
||||||
|
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-break small">{d.link}</span>
|
||||||
|
)}
|
||||||
|
{canPreview && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-outline-info py-0 px-1"
|
||||||
|
style={{ fontSize: "0.6rem" }}
|
||||||
|
onClick={() => setViewer({ url: d.localLink!, title: fileName })}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{d.localLink && (
|
||||||
|
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
|
||||||
|
Local Mirror
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||||
|
<td><code style={{ fontSize: "0.75rem" }}>{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 className="small">{d.comments ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Scraps / Media</h5>
|
||||||
|
{groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>}
|
||||||
|
{groupedScraps.map(([type, items]) => (
|
||||||
|
<div key={type} className="mb-4">
|
||||||
|
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Link</th>
|
||||||
|
<th style={{ width: 100 }} className="text-end">Size</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Rationale</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items?.map((s) => {
|
||||||
|
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
|
||||||
|
const fileName = s.link?.split("/").pop() || "file";
|
||||||
|
const canPreview = s.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
|
||||||
|
return (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{s.link ? (
|
||||||
|
isHttp ? (
|
||||||
|
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-break small">{s.link}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
{canPreview && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-outline-info py-0 px-1"
|
||||||
|
style={{ fontSize: "0.6rem" }}
|
||||||
|
onClick={() => setViewer({ url: s.localLink!, title: fileName })}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.localLink && (
|
||||||
|
<a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
|
||||||
|
Local Mirror
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 className="small">{s.rationale}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
{viewer && (
|
||||||
|
<FileViewer
|
||||||
|
url={viewer.url}
|
||||||
|
title={viewer.title}
|
||||||
|
onClose={() => setViewer(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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} />;
|
||||||
|
}
|
||||||
68
src/app/zxdb/releases/page.tsx
Normal file
68
src/app/zxdb/releases/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
function parseIdList(value: string | string[] | undefined) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const raw = Array.isArray(value) ? value.join(",") : value;
|
||||||
|
const ids = raw
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 preferredMachineIds = [27, 26, 8, 9];
|
||||||
|
const dMachinetypeIds = parseIdList(sp.dMachinetypeId) ?? preferredMachineIds;
|
||||||
|
const dMachinetypeIdStr = dMachinetypeIds.join(",");
|
||||||
|
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: dMachinetypeIds, 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/FileViewer.tsx
Normal file
90
src/components/FileViewer.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
type FileViewerProps = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FileViewer({ url, title, onClose }: FileViewerProps) {
|
||||||
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isText = title.toLowerCase().endsWith(".txt") || title.toLowerCase().endsWith(".nfo");
|
||||||
|
const isImage = title.toLowerCase().match(/\.(png|jpg|jpeg|gif)$/);
|
||||||
|
const isPdf = title.toLowerCase().endsWith(".pdf");
|
||||||
|
|
||||||
|
const viewUrl = url.includes("?") ? `${url}&view=1` : `${url}?view=1`;
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
if (isText) {
|
||||||
|
fetch(viewUrl)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Failed to load file");
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
setContent(text);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show size="xl" onHide={onClose} centered scrollable>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{title}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body className="p-0 bg-dark text-light" style={{ minHeight: "300px" }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: "300px" }}>
|
||||||
|
<Spinner animation="border" variant="light" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<p className="text-danger">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{isText && (
|
||||||
|
<pre className="p-3 m-0" style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: "0.9rem", color: "#ccc" }}>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{isImage && (
|
||||||
|
<div className="text-center p-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={viewUrl} alt={title} className="img-fluid" style={{ maxHeight: "80vh" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPdf && (
|
||||||
|
<iframe src={viewUrl} style={{ width: "100%", height: "80vh", border: "none" }} title={title} />
|
||||||
|
)}
|
||||||
|
{!isText && !isImage && !isPdf && (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<p>Preview not available for this file type.</p>
|
||||||
|
<a href={url} className="btn btn-primary">Download File</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose}>Close</Button>
|
||||||
|
<a href={url} className="btn btn-success" download>Download</a>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/components/explorer/ExplorerLayout.tsx
Normal file
39
src/components/explorer/ExplorerLayout.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import FilterChips from "./FilterChips";
|
||||||
|
|
||||||
|
type ExplorerLayoutProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
chips?: string[];
|
||||||
|
onClearChips?: () => void;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExplorerLayout({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
chips = [],
|
||||||
|
onClearChips,
|
||||||
|
sidebar,
|
||||||
|
children,
|
||||||
|
}: ExplorerLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">{title}</h1>
|
||||||
|
{subtitle ? <div className="text-secondary">{subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
{chips.length > 0 ? (
|
||||||
|
<FilterChips chips={chips} onClear={onClearChips} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">{sidebar}</div>
|
||||||
|
<div className="col-lg-9">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/explorer/FilterChips.tsx
Normal file
20
src/components/explorer/FilterChips.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
type FilterChipsProps = {
|
||||||
|
chips: string[];
|
||||||
|
onClear?: () => void;
|
||||||
|
clearLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FilterChips({ chips, onClear, clearLabel = "Clear filters" }: FilterChipsProps) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<span key={chip} className="badge text-bg-light">{chip}</span>
|
||||||
|
))}
|
||||||
|
{onClear ? (
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClear}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/explorer/FilterSidebar.tsx
Normal file
13
src/components/explorer/FilterSidebar.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
type FilterSidebarProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FilterSidebar({ children }: FilterSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/explorer/MultiSelectChips.tsx
Normal file
37
src/components/explorer/MultiSelectChips.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
type ChipOption<T extends number | string> = {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultiSelectChipsProps<T extends number | string> = {
|
||||||
|
options: ChipOption<T>[];
|
||||||
|
selected: T[];
|
||||||
|
onToggle: (id: T) => void;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MultiSelectChips<T extends number | string>({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
size = "sm",
|
||||||
|
}: MultiSelectChipsProps<T>) {
|
||||||
|
const btnSize = size === "sm" ? "btn-sm" : "";
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = selected.includes(option.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(option.id)}
|
||||||
|
type="button"
|
||||||
|
className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`}
|
||||||
|
onClick={() => onToggle(option.id)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/env.ts
Normal file
54
src/env.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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(),
|
||||||
|
|
||||||
|
// Local file paths for mirroring
|
||||||
|
ZXDB_LOCAL_FILEPATH: z.string().optional(),
|
||||||
|
WOS_LOCAL_FILEPATH: 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;
|
||||||
2707
src/server/repo/zxdb.ts
Normal file
2707
src/server/repo/zxdb.ts
Normal file
File diff suppressed because it is too large
Load Diff
661
src/server/schema/zxdb.ts
Normal file
661
src/server/schema/zxdb.ts
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext, bigint, timestamp } 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"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Derived tables (managed by update scripts, not part of ZXDB upstream) ----
|
||||||
|
|
||||||
|
// Stores MD5, CRC32 and size of the inner tape file extracted from download zips.
|
||||||
|
// Populated by bin/update-software-hashes.mjs; survives DB wipes via JSON snapshot.
|
||||||
|
export const softwareHashes = mysqlTable("software_hashes", {
|
||||||
|
downloadId: int("download_id").notNull().primaryKey(),
|
||||||
|
md5: varchar("md5", { length: 32 }).notNull(),
|
||||||
|
crc32: varchar("crc32", { length: 8 }).notNull(),
|
||||||
|
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||||
|
innerPath: varchar("inner_path", { length: 500 }).notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
164
src/utils/md5.ts
Normal file
164
src/utils/md5.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Pure-JS MD5 for browser use (Web Crypto doesn't support MD5).
|
||||||
|
// Standard RFC 1321 implementation, typed for TypeScript.
|
||||||
|
|
||||||
|
function md5cycle(x: number[], k: number[]) {
|
||||||
|
let a = x[0], b = x[1], c = x[2], d = x[3];
|
||||||
|
|
||||||
|
a = ff(a, b, c, d, k[0], 7, -680876936);
|
||||||
|
d = ff(d, a, b, c, k[1], 12, -389564586);
|
||||||
|
c = ff(c, d, a, b, k[2], 17, 606105819);
|
||||||
|
b = ff(b, c, d, a, k[3], 22, -1044525330);
|
||||||
|
a = ff(a, b, c, d, k[4], 7, -176418897);
|
||||||
|
d = ff(d, a, b, c, k[5], 12, 1200080426);
|
||||||
|
c = ff(c, d, a, b, k[6], 17, -1473231341);
|
||||||
|
b = ff(b, c, d, a, k[7], 22, -45705983);
|
||||||
|
a = ff(a, b, c, d, k[8], 7, 1770035416);
|
||||||
|
d = ff(d, a, b, c, k[9], 12, -1958414417);
|
||||||
|
c = ff(c, d, a, b, k[10], 17, -42063);
|
||||||
|
b = ff(b, c, d, a, k[11], 22, -1990404162);
|
||||||
|
a = ff(a, b, c, d, k[12], 7, 1804603682);
|
||||||
|
d = ff(d, a, b, c, k[13], 12, -40341101);
|
||||||
|
c = ff(c, d, a, b, k[14], 17, -1502002290);
|
||||||
|
b = ff(b, c, d, a, k[15], 22, 1236535329);
|
||||||
|
|
||||||
|
a = gg(a, b, c, d, k[1], 5, -165796510);
|
||||||
|
d = gg(d, a, b, c, k[6], 9, -1069501632);
|
||||||
|
c = gg(c, d, a, b, k[11], 14, 643717713);
|
||||||
|
b = gg(b, c, d, a, k[0], 20, -373897302);
|
||||||
|
a = gg(a, b, c, d, k[5], 5, -701558691);
|
||||||
|
d = gg(d, a, b, c, k[10], 9, 38016083);
|
||||||
|
c = gg(c, d, a, b, k[15], 14, -660478335);
|
||||||
|
b = gg(b, c, d, a, k[4], 20, -405537848);
|
||||||
|
a = gg(a, b, c, d, k[9], 5, 568446438);
|
||||||
|
d = gg(d, a, b, c, k[14], 9, -1019803690);
|
||||||
|
c = gg(c, d, a, b, k[3], 14, -187363961);
|
||||||
|
b = gg(b, c, d, a, k[8], 20, 1163531501);
|
||||||
|
a = gg(a, b, c, d, k[13], 5, -1444681467);
|
||||||
|
d = gg(d, a, b, c, k[2], 9, -51403784);
|
||||||
|
c = gg(c, d, a, b, k[7], 14, 1735328473);
|
||||||
|
b = gg(b, c, d, a, k[12], 20, -1926607734);
|
||||||
|
|
||||||
|
a = hh(a, b, c, d, k[5], 4, -378558);
|
||||||
|
d = hh(d, a, b, c, k[8], 11, -2022574463);
|
||||||
|
c = hh(c, d, a, b, k[11], 16, 1839030562);
|
||||||
|
b = hh(b, c, d, a, k[14], 23, -35309556);
|
||||||
|
a = hh(a, b, c, d, k[1], 4, -1530992060);
|
||||||
|
d = hh(d, a, b, c, k[4], 11, 1272893353);
|
||||||
|
c = hh(c, d, a, b, k[7], 16, -155497632);
|
||||||
|
b = hh(b, c, d, a, k[10], 23, -1094730640);
|
||||||
|
a = hh(a, b, c, d, k[13], 4, 681279174);
|
||||||
|
d = hh(d, a, b, c, k[0], 11, -358537222);
|
||||||
|
c = hh(c, d, a, b, k[3], 16, -722521979);
|
||||||
|
b = hh(b, c, d, a, k[6], 23, 76029189);
|
||||||
|
a = hh(a, b, c, d, k[9], 4, -640364487);
|
||||||
|
d = hh(d, a, b, c, k[12], 11, -421815835);
|
||||||
|
c = hh(c, d, a, b, k[15], 16, 530742520);
|
||||||
|
b = hh(b, c, d, a, k[2], 23, -995338651);
|
||||||
|
|
||||||
|
a = ii(a, b, c, d, k[0], 6, -198630844);
|
||||||
|
d = ii(d, a, b, c, k[7], 10, 1126891415);
|
||||||
|
c = ii(c, d, a, b, k[14], 15, -1416354905);
|
||||||
|
b = ii(b, c, d, a, k[5], 21, -57434055);
|
||||||
|
a = ii(a, b, c, d, k[12], 6, 1700485571);
|
||||||
|
d = ii(d, a, b, c, k[3], 10, -1894986606);
|
||||||
|
c = ii(c, d, a, b, k[10], 15, -1051523);
|
||||||
|
b = ii(b, c, d, a, k[1], 21, -2054922799);
|
||||||
|
a = ii(a, b, c, d, k[8], 6, 1873313359);
|
||||||
|
d = ii(d, a, b, c, k[15], 10, -30611744);
|
||||||
|
c = ii(c, d, a, b, k[6], 15, -1560198380);
|
||||||
|
b = ii(b, c, d, a, k[13], 21, 1309151649);
|
||||||
|
a = ii(a, b, c, d, k[4], 6, -145523070);
|
||||||
|
d = ii(d, a, b, c, k[11], 10, -1120210379);
|
||||||
|
c = ii(c, d, a, b, k[2], 15, 718787259);
|
||||||
|
b = ii(b, c, d, a, k[9], 21, -343485551);
|
||||||
|
|
||||||
|
x[0] = add32(a, x[0]);
|
||||||
|
x[1] = add32(b, x[1]);
|
||||||
|
x[2] = add32(c, x[2]);
|
||||||
|
x[3] = add32(d, x[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
|
||||||
|
a = add32(add32(a, q), add32(x, t));
|
||||||
|
return add32((a << s) | (a >>> (32 - s)), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & c) | (~b & d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & d) | (c & ~d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(b ^ c ^ d, a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(c ^ (b | ~d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function add32(a: number, b: number) {
|
||||||
|
return (a + b) & 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5blk(s: Uint8Array, offset: number): number[] {
|
||||||
|
const md5blks: number[] = [];
|
||||||
|
for (let i = 0; i < 64; i += 4) {
|
||||||
|
md5blks[i >> 2] =
|
||||||
|
s[offset + i] +
|
||||||
|
(s[offset + i + 1] << 8) +
|
||||||
|
(s[offset + i + 2] << 16) +
|
||||||
|
(s[offset + i + 3] << 24);
|
||||||
|
}
|
||||||
|
return md5blks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = "0123456789abcdef".split("");
|
||||||
|
|
||||||
|
function rhex(n: number) {
|
||||||
|
let s = "";
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
s += hex[(n >> (j * 8 + 4)) & 0x0f] + hex[(n >> (j * 8)) & 0x0f];
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5raw(bytes: Uint8Array): string {
|
||||||
|
const n = bytes.length;
|
||||||
|
const state = [1732584193, -271733879, -1732584194, 271733878];
|
||||||
|
|
||||||
|
let i: number;
|
||||||
|
for (i = 64; i <= n; i += 64) {
|
||||||
|
md5cycle(state, md5blk(bytes, i - 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail: copy remaining bytes into a padded buffer
|
||||||
|
const tail = new Uint8Array(64);
|
||||||
|
const remaining = n - (i - 64);
|
||||||
|
for (let j = 0; j < remaining; j++) {
|
||||||
|
tail[j] = bytes[i - 64 + j];
|
||||||
|
}
|
||||||
|
tail[remaining] = 0x80;
|
||||||
|
|
||||||
|
// If remaining >= 56 we need an extra block
|
||||||
|
if (remaining >= 56) {
|
||||||
|
md5cycle(state, md5blk(tail, 0));
|
||||||
|
tail.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append bit length as 64-bit little-endian
|
||||||
|
const bitLen = n * 8;
|
||||||
|
tail[56] = bitLen & 0xff;
|
||||||
|
tail[57] = (bitLen >> 8) & 0xff;
|
||||||
|
tail[58] = (bitLen >> 16) & 0xff;
|
||||||
|
tail[59] = (bitLen >> 24) & 0xff;
|
||||||
|
// For files < 512 MB the high 32 bits are 0; safe for tape images
|
||||||
|
md5cycle(state, md5blk(tail, 0));
|
||||||
|
|
||||||
|
return rhex(state[0]) + rhex(state[1]) + rhex(state[2]) + rhex(state[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a File as ArrayBuffer and returns its MD5 hex digest.
|
||||||
|
export async function computeMd5(file: File): Promise<string> {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
return md5raw(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user