Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 240936a850 | |||
| ddbf72ea52 | |||
| 3ef3a16bc0 | |||
| 54cfe4f175 | |||
| ad77b47117 | |||
| 3fe6f980c6 | |||
| dbbad09b1b | |||
| 4222eba8ba | |||
| 79aabd9b62 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ next-env.d.ts
|
|||||||
# PNPM build artifacts
|
# PNPM build artifacts
|
||||||
.pnpm
|
.pnpm
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "ZXDB"]
|
||||||
|
path = ZXDB
|
||||||
|
url = https://github.com/zxdb/ZXDB
|
||||||
151
AGENTS.md
Normal file
151
AGENTS.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# AGENT.md
|
||||||
|
|
||||||
|
This document provides an overview of the Next Explorer project, its structure, and its implementation details.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Next Explorer is a web application for exploring the Spectrum Next ecosystem. It is built with Next.js (App Router), React, and TypeScript.
|
||||||
|
|
||||||
|
It has two main areas:
|
||||||
|
- Registers: parsed from `data/nextreg.txt`, browsable with real-time filtering and deep links.
|
||||||
|
- ZXDB Explorer: a deep, cross‑linked browser for ZXDB entries, labels, genres, languages, and machine types backed by MySQL using Drizzle ORM.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
The project is a Next.js application with the following structure:
|
||||||
|
```
|
||||||
|
|
||||||
|
next-explorer/
|
||||||
|
├── eslint.config.mjs
|
||||||
|
├── next.config.ts
|
||||||
|
├── package.json
|
||||||
|
├── pnpm-lock.yaml
|
||||||
|
├── tsconfig.json
|
||||||
|
├── data/
|
||||||
|
│ ├── nextreg.txt
|
||||||
|
│ ├── custom_parsers.txt
|
||||||
|
│ └── wikilinks.txt
|
||||||
|
├── node_modules/...
|
||||||
|
├── public/...
|
||||||
|
└── src/
|
||||||
|
├── middleware.js
|
||||||
|
├── app/
|
||||||
|
│ ├── layout.tsx
|
||||||
|
│ ├── page.module.css
|
||||||
|
│ ├── page.tsx
|
||||||
|
│ └── registers/
|
||||||
|
│ ├── page.tsx
|
||||||
|
│ ├── RegisterBrowser.tsx
|
||||||
|
│ ├── RegisterDetail.tsx
|
||||||
|
│ └── [hex]/
|
||||||
|
│ └── page.tsx
|
||||||
|
├── components/
|
||||||
|
│ ├── Navbar.tsx
|
||||||
|
│ └── ThemeDropdown.tsx
|
||||||
|
├── scss/
|
||||||
|
│ ├── _bootswatch.scss
|
||||||
|
│ ├── _explorer.scss
|
||||||
|
│ ├── _variables.scss
|
||||||
|
│ └── nbn.scss
|
||||||
|
├── services/
|
||||||
|
│ └── register.service.ts
|
||||||
|
└── utils/
|
||||||
|
├── register_parser.ts
|
||||||
|
└── register_parsers/
|
||||||
|
├── reg_default.ts
|
||||||
|
└── reg_f0.ts
|
||||||
|
```
|
||||||
|
- **`data/`**: Contains the raw input data for the Spectrum Next explorer.
|
||||||
|
- `nextreg.txt`: Main register definition file.
|
||||||
|
- `custom_parsers.txt`, `wikilinks.txt`: Auxiliary configuration/data used by the parser.
|
||||||
|
- **`src/app/`**: Next.js App Router entrypoint.
|
||||||
|
- `layout.tsx`: Root layout for all routes.
|
||||||
|
- `page.tsx`: Application home page.
|
||||||
|
- `registers/`: Routes and components for the register explorer.
|
||||||
|
- `page.tsx`: Server Component that loads and lists all registers.
|
||||||
|
- `RegisterBrowser.tsx`: Client Component implementing search/filter and listing.
|
||||||
|
- `RegisterDetail.tsx`: Client Component that renders a single register’s details, including modes, notes, and source modal.
|
||||||
|
- `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address.
|
||||||
|
- `src/app/zxdb/`: ZXDB Explorer routes and client components.
|
||||||
|
- `page.tsx` + `ZxdbExplorer.tsx`: Search + filters with server-rendered initial content and ISR.
|
||||||
|
- `entries/[id]/page.tsx` + `EntryDetail.tsx`: Entry details (SSR initial data).
|
||||||
|
- `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail.
|
||||||
|
- `genres/`, `languages/`, `machinetypes/`: Category hubs and detail pages.
|
||||||
|
- `src/app/api/zxdb/`: Zod‑validated API routes (Node runtime) for search and category browsing.
|
||||||
|
- `src/server/`:
|
||||||
|
- `env.ts`: Zod env parsing/validation (t3.gg style). Validates `ZXDB_URL` (mysql://).
|
||||||
|
- `server/db.ts`: Drizzle over `mysql2` singleton pool.
|
||||||
|
- `server/schema/zxdb.ts`: Minimal Drizzle models (entries, labels, helper tables, lookups).
|
||||||
|
- `server/repo/zxdb.ts`: Repository queries for search, details, categories, and facets.
|
||||||
|
- **`src/components/`**: Shared UI components such as `Navbar` and `ThemeDropdown`.
|
||||||
|
- **`src/services/register.service.ts`**: Service layer responsible for loading and caching parsed register data.
|
||||||
|
- **`src/utils/register_parser.ts` & `src/utils/register_parsers/`**: Parsing logic for `nextreg.txt`, including mode/bitfield handling and any register-specific parsing extensions.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
Comment what the code does, not what the agent has done. The documentation's purpose is the state of the application today, not a log of actions taken.
|
||||||
|
|
||||||
|
### Data Parsing
|
||||||
|
|
||||||
|
- `getRegisters()` in `src/services/register.service.ts`:
|
||||||
|
- Reads `data/nextreg.txt` from disk.
|
||||||
|
- Uses `parseNextReg()` from `src/utils/register_parser.ts` to convert the raw text into an array of `Register` objects.
|
||||||
|
- Returns the in-memory representation of all registers (and can be extended to cache results across calls).
|
||||||
|
|
||||||
|
- `parseNextReg()` and related helpers in `register_parser.ts`:
|
||||||
|
- Parse the custom `nextreg.txt` format into structured data:
|
||||||
|
- Register addresses (hex/dec).
|
||||||
|
- Names, notes, and descriptive text.
|
||||||
|
- Per-mode read/write/common bitfield views.
|
||||||
|
- Optional source lines and external links (e.g. wiki URLs).
|
||||||
|
- Delegate special-case parsing to functions in `src/utils/register_parsers/` (e.g. `reg_default.ts`, `reg_f0.ts`) when needed.
|
||||||
|
|
||||||
|
### React / Next.js Patterns
|
||||||
|
|
||||||
|
- **Server Components**:
|
||||||
|
- `src/app/registers/page.tsx` and `src/app/registers/[hex]/page.tsx` are Server Components.
|
||||||
|
- ZXDB pages under `/zxdb` server‑render initial content for fast first paint, with ISR (`export const revalidate = 3600`) on non‑search pages.
|
||||||
|
- Server components call repository functions directly on the server and pass data to client components for presentation.
|
||||||
|
|
||||||
|
- **Client Components**:
|
||||||
|
- `RegisterBrowser.tsx`:
|
||||||
|
- Marked with `'use client'`.
|
||||||
|
- Uses React state to manage search input and filtered results.
|
||||||
|
- `RegisterDetail.tsx`:
|
||||||
|
- Marked with `'use client'`.
|
||||||
|
- Renders a single register with tabs for different access modes.
|
||||||
|
- ZXDB client components (e.g., `ZxdbExplorer.tsx`, `EntryDetail.tsx`, `labels/*`) receive initial data from the server and keep interactions on the client without blocking the first paint.
|
||||||
|
|
||||||
|
- **Dynamic Routing**:
|
||||||
|
- Pages and API routes must await dynamic params in Next.js 15:
|
||||||
|
- Pages: `export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; }`
|
||||||
|
- API: `export async function GET(req, ctx: { params: Promise<{ id: string }> }) { const raw = await ctx.params; /* validate with Zod */ }`
|
||||||
|
- `src/app/registers/[hex]/page.tsx` resolves the `[hex]` segment and calls `notFound()` if absent.
|
||||||
|
|
||||||
|
### ZXDB Integration
|
||||||
|
|
||||||
|
- Database connection via `mysql2` pool wrapped by Drizzle (`src/server/db.ts`).
|
||||||
|
- Env validation via Zod (`src/env.ts`) ensures `ZXDB_URL` is a valid `mysql://` URL.
|
||||||
|
- Minimal Drizzle schema models used for fast search and lookups (`src/server/schema/zxdb.ts`).
|
||||||
|
- Repository consolidates SQL with typed results (`src/server/repo/zxdb.ts`).
|
||||||
|
- API routes under `/api/zxdb/*` validate inputs with Zod and run on Node runtime.
|
||||||
|
- UI under `/zxdb` is deeply cross‑linked and server‑renders initial data for performance. Links use Next `Link` to enable prefetching.
|
||||||
|
- Helper SQL `ZXDB/scripts/ZXDB_help_search.sql` must be run to create `search_by_*` tables for efficient searches.
|
||||||
|
- Lookup tables use column `text` for display names; the Drizzle schema maps it as `name`.
|
||||||
|
|
||||||
|
### Working Patterns***
|
||||||
|
|
||||||
|
- git branching:
|
||||||
|
- Do not create new branches
|
||||||
|
- git commits:
|
||||||
|
- Create COMMIT_EDITMSG file, await any user edits, then commit using that
|
||||||
|
commit note, and then delete the COMMIT_EDITMSG file. Remember to keep
|
||||||
|
the first line as the subject <50char
|
||||||
|
- git commit messages:
|
||||||
|
- Use imperative mood (e.g., "Add feature X", "Fix bug Y").
|
||||||
|
- Include relevant issue numbers if applicable.
|
||||||
|
- Sign-off commit message as Junie@<hostname>
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- ZXDB setup and API usage: `docs/ZXDB.md`
|
||||||
15
COMMIT_EDITMSG
Normal file
15
COMMIT_EDITMSG
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
docs: add ZXDB guide; refresh README & AGENTS
|
||||||
|
|
||||||
|
Expand and update documentation to reflect the current app (Registers + ZXDB Explorer), with clear setup and usage instructions.
|
||||||
|
|
||||||
|
Changes
|
||||||
|
- README: add project overview including ZXDB Explorer; routes tour; ZXDB setup (DB import, helper search tables, readonly role); environment configuration; selected API endpoints; implementation notes (Next 15 async params, Node runtime for mysql2, SSR/ISR usage); links to AGENTS.md and docs/ZXDB.md.
|
||||||
|
- docs/ZXDB.md (new): deep-dive guide covering database preparation, helper tables, environment, Explorer UI, API reference under /api/zxdb, performance approach (helper tables, parallel queries, ISR), troubleshooting, and roadmap.
|
||||||
|
- AGENTS.md: refresh Project Overview/Structure with ZXDB routes and server/client boundaries; document Next.js 15 dynamic params async pattern for pages and API routes; note Drizzle+mysql2, Node runtime, and lookup `text`→`name` mapping; keep commit workflow guidance.
|
||||||
|
- example.env: add reference to docs/ZXDB.md and clarify mysql:// format and setup pointers.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
- Documentation focuses on the current state of the codebase (what the code does), not a log of agent actions.
|
||||||
|
- Helper SQL at ZXDB/scripts/ZXDB_help_search.sql is required for performant searches.
|
||||||
|
|
||||||
|
Signed-off-by: Junie@lucy.xalior.com
|
||||||
61
README.md
61
README.md
@@ -1,14 +1,13 @@
|
|||||||
Spectrum Next Explorer
|
Spectrum Next Explorer
|
||||||
|
|
||||||
A Next.js application for exploring the Spectrum Next hardware. It includes a Register Explorer with real‑time search and deep‑linkable queries.
|
A Next.js application for exploring the Spectrum Next ecosystem. It ships with:
|
||||||
|
|
||||||
Features
|
- Register Explorer: parsed from `data/nextreg.txt`, with real‑time search and deep links
|
||||||
- Register Explorer parsed from `data/nextreg.txt`
|
- ZXDB Explorer: a deep, cross‑linked browser for entries, labels, genres, languages, and machine types backed by a MySQL ZXDB instance
|
||||||
- Real‑time filtering with query‑string deep links (e.g. `/registers?q=vram`)
|
|
||||||
- Bootstrap 5 theme with light/dark support
|
- Bootstrap 5 theme with light/dark support
|
||||||
|
|
||||||
Quick start
|
Quick start
|
||||||
- Prerequisites: Node.js 20+, pnpm (recommended)
|
- Prerequisites: Node.js 20+, pnpm (recommended), access to a MySQL server for ZXDB (optional for Registers)
|
||||||
- Install dependencies:
|
- Install dependencies:
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
- Run in development (Turbopack, port 4000):
|
- Run in development (Turbopack, port 4000):
|
||||||
@@ -26,11 +25,53 @@ Project scripts (package.json)
|
|||||||
- `deploy-test`: push to `test.explorer.specnext.dev`
|
- `deploy-test`: push to `test.explorer.specnext.dev`
|
||||||
- `deploy-prod`: push to `explorer.specnext.dev`
|
- `deploy-prod`: push to `explorer.specnext.dev`
|
||||||
|
|
||||||
Documentation
|
Routes
|
||||||
- Docs index: `docs/index.md`
|
- `/` — Home
|
||||||
- Getting Started: `docs/getting-started.md`
|
- `/registers` — Register Explorer
|
||||||
- Architecture: `docs/architecture.md`
|
- `/zxdb` — ZXDB Explorer (search + filters)
|
||||||
- Register Explorer: `docs/registers.md`
|
- `/zxdb/entries/[id]` — Entry detail
|
||||||
|
- `/zxdb/labels` and `/zxdb/labels/[id]` — Labels search and detail
|
||||||
|
- `/zxdb/genres` and `/zxdb/genres/[id]` — Genres list and entries
|
||||||
|
- `/zxdb/languages` and `/zxdb/languages/[id]` — Languages list and entries
|
||||||
|
- `/zxdb/machinetypes` and `/zxdb/machinetypes/[id]` — Machine types list and entries
|
||||||
|
|
||||||
|
ZXDB setup (database, env, and helper tables)
|
||||||
|
The Registers section works without any database. The ZXDB Explorer requires a MySQL ZXDB database and one environment variable.
|
||||||
|
|
||||||
|
1) Prepare the database (outside this app)
|
||||||
|
- Import ZXDB data into MySQL. If you want only structure, use `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` in this repo. For data, import ZXDB via your normal process.
|
||||||
|
- Create the helper search tables (required for fast search):
|
||||||
|
- Run `ZXDB/scripts/ZXDB_help_search.sql` against your ZXDB database.
|
||||||
|
- Create a read‑only role/user (recommended):
|
||||||
|
- Example (see `bin/import_mysql.sh`):
|
||||||
|
- Create role `zxdb_readonly`
|
||||||
|
- Grant `SELECT, SHOW VIEW` on database `zxdb`
|
||||||
|
|
||||||
|
2) Configure environment
|
||||||
|
- Copy `.env` from `example.env`.
|
||||||
|
- Set `ZXDB_URL` to a MySQL URL, e.g. `mysql://zxdb_readonly:password@hostname:3306/zxdb`.
|
||||||
|
- On startup, `src/env.ts` validates env vars (t3.gg pattern with Zod) and will fail fast if invalid.
|
||||||
|
|
||||||
|
3) Run the app
|
||||||
|
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`.
|
||||||
|
|
||||||
|
API (selected endpoints)
|
||||||
|
- `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1`
|
||||||
|
- `GET /api/zxdb/entries/[id]`
|
||||||
|
- `GET /api/zxdb/labels/search?q=...`
|
||||||
|
- `GET /api/zxdb/labels/[id]?page=1&pageSize=20`
|
||||||
|
- `GET /api/zxdb/genres` and `/api/zxdb/genres/[id]?page=1`
|
||||||
|
- `GET /api/zxdb/languages` and `/api/zxdb/languages/[id]?page=1`
|
||||||
|
- `GET /api/zxdb/machinetypes` and `/api/zxdb/machinetypes/[id]?page=1`
|
||||||
|
|
||||||
|
Implementation notes
|
||||||
|
- Next.js 15 dynamic params: pages and API routes that consume `params` must await it, e.g. `export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; }`
|
||||||
|
- ZXDB integration uses Drizzle ORM over `mysql2` with a singleton pool at `src/server/db.ts`; API routes declare `export const runtime = "nodejs"`.
|
||||||
|
- Entry and detail pages server‑render initial content and use ISR (`revalidate = 3600`) for fast time‑to‑content; index pages avoid a blocking first client fetch.
|
||||||
|
|
||||||
|
Further reading
|
||||||
|
- ZXDB details and API usage: `docs/ZXDB.md`
|
||||||
|
- Agent/developer workflow and commit guidelines: `AGENTS.md`
|
||||||
|
|
||||||
License
|
License
|
||||||
- See `LICENSE.txt` for details.
|
- See `LICENSE.txt` for details.
|
||||||
|
|||||||
1
ZXDB
Submodule
1
ZXDB
Submodule
Submodule ZXDB added at 3784c91bdd
12
bin/import_mysql.sh
Normal file
12
bin/import_mysql.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
mysql -uroot -p -hquinn < ZXDB/ZXDB_mysql.sql
|
||||||
|
{ 1 ↵ git:‹feat/zxdb ✗› v22.21.1
|
||||||
|
echo "SET @OLD_SQL_MODE := @@SESSION.sql_mode;"
|
||||||
|
echo "SET SESSION sql_mode := REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '');"
|
||||||
|
cat ZXDB/scripts/ZXDB_help_search.sql
|
||||||
|
echo "SET SESSION sql_mode := @OLD_SQL_MODE;"
|
||||||
|
echo "CREATE ROLE 'zxdb_readonly';"
|
||||||
|
echo "GRANT SELECT, SHOW VIEW ON `zxdb`.* TO 'zxdb_readonly';"
|
||||||
|
} | mysql -uroot -p -hquinn zxdb
|
||||||
|
mysqldump --no-data -hquinn -uroot -p zxdb > ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
|
||||||
112
docs/ZXDB.md
Normal file
112
docs/ZXDB.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# ZXDB Guide
|
||||||
|
|
||||||
|
This document explains how the ZXDB Explorer works in this project, how to set up the database connection, and how to use the built‑in API and UI for software discovery.
|
||||||
|
|
||||||
|
## What is ZXDB?
|
||||||
|
|
||||||
|
ZXDB ( https://github.com/zxdb/ZXDB )is a community‑maintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in read‑only mode and expose a fast, cross‑linked explorer UI under `/zxdb`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- MySQL server with ZXDB data (or at minimum the tables; data is needed to browse).
|
||||||
|
- Ability to run the helper SQL that builds search tables (required for efficient LIKE searches).
|
||||||
|
- A read‑only MySQL user for the app (recommended).
|
||||||
|
|
||||||
|
## Database setup
|
||||||
|
|
||||||
|
1. Import ZXDB data into MySQL.
|
||||||
|
- For structure only (no data): use `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` in this repo.
|
||||||
|
- For actual data, follow your usual ZXDB data import process.
|
||||||
|
|
||||||
|
2. Create helper search tables (required).
|
||||||
|
- Run `ZXDB/scripts/ZXDB_help_search.sql` on your ZXDB database.
|
||||||
|
- This creates `search_by_titles`, `search_by_names`, `search_by_authors`, and `search_by_publishers` tables.
|
||||||
|
|
||||||
|
3. Create a read‑only role/user (recommended).
|
||||||
|
- Example (see `bin/import_mysql.sh`):
|
||||||
|
- Create role `zxdb_readonly`.
|
||||||
|
- Grant `SELECT, SHOW VIEW` on your `zxdb` database to the user.
|
||||||
|
|
||||||
|
## Environment configuration
|
||||||
|
|
||||||
|
Set the connection string in `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The URL must start with `mysql://`. Env is validated at boot by `src/env.ts` (Zod), failing fast if misconfigured.
|
||||||
|
- The app uses a singleton `mysql2` pool (`src/server/db.ts`) and Drizzle ORM for typed queries.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
# open http://localhost:4000 and navigate to /zxdb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Explorer UI overview
|
||||||
|
|
||||||
|
- `/zxdb` — Search entries by title and filter by genre, language, and machine type; sort and paginate results.
|
||||||
|
- `/zxdb/entries/[id]` — Entry details with badges for genre/language/machine, and linked authors/publishers.
|
||||||
|
- `/zxdb/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies) and view authored/published entries.
|
||||||
|
- `/zxdb/genres`, `/zxdb/languages`, `/zxdb/machinetypes` — Category hubs with linked detail pages listing entries.
|
||||||
|
|
||||||
|
Cross‑linking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched.
|
||||||
|
|
||||||
|
Performance: Detail and index pages are server‑rendered with initial data and use ISR (`revalidate = 3600`) to reduce time‑to‑first‑content. Queries select only required columns and leverage helper tables for text search.
|
||||||
|
|
||||||
|
## HTTP API reference (selected)
|
||||||
|
|
||||||
|
All endpoints are under `/api/zxdb` and validate inputs with Zod. Responses are JSON.
|
||||||
|
|
||||||
|
- Search entries
|
||||||
|
- `GET /api/zxdb/search`
|
||||||
|
- Query params:
|
||||||
|
- `q` — string (free‑text search; normalized via helper tables)
|
||||||
|
- `page`, `pageSize` — pagination (default pageSize=20, max=100)
|
||||||
|
- `genreId`, `languageId`, `machinetypeId` — optional filters
|
||||||
|
- `sort` — `title` or `id_desc`
|
||||||
|
- `facets` — boolean; if truthy, includes facet counts for genres/languages/machines
|
||||||
|
|
||||||
|
- Entry detail
|
||||||
|
- `GET /api/zxdb/entries/[id]`
|
||||||
|
- Returns: entry core fields, joined genre/language/machinetype names, authors and publishers.
|
||||||
|
|
||||||
|
- Labels
|
||||||
|
- `GET /api/zxdb/labels/search?q=...`
|
||||||
|
- `GET /api/zxdb/labels/[id]?page=1&pageSize=20` — includes `authored` and `published` lists.
|
||||||
|
|
||||||
|
- Categories
|
||||||
|
- `GET /api/zxdb/genres` and `/api/zxdb/genres/[id]?page=1`
|
||||||
|
- `GET /api/zxdb/languages` and `/api/zxdb/languages/[id]?page=1`
|
||||||
|
- `GET /api/zxdb/machinetypes` and `/api/zxdb/machinetypes/[id]?page=1`
|
||||||
|
|
||||||
|
Runtime: API routes declare `export const runtime = "nodejs"` to support `mysql2`.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- Drizzle models map ZXDB lookup table column `text` to property `name` for ergonomics (e.g., `languages.text` → `name`).
|
||||||
|
- Next.js 15 dynamic params must be awaited in App Router pages and API routes. Example:
|
||||||
|
```ts
|
||||||
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Repository queries parallelize independent calls with `Promise.all` for lower latency.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- 400 from dynamic API routes: ensure you await `ctx.params` before Zod validation.
|
||||||
|
- Unknown column errors for lookup names: ZXDB tables use column `text` for names; Drizzle schema must select `text` as `name`.
|
||||||
|
- Slow entry page: confirm server‑rendering is active and ISR is set; client components should not fetch on the first paint when initial props are provided.
|
||||||
|
- MySQL auth or network errors: verify `ZXDB_URL` and that your user has read permissions.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- Facet counts displayed in the `/zxdb` filter UI.
|
||||||
|
- Breadcrumbs and additional a11y polish.
|
||||||
|
- Media assets and download links per release (future).
|
||||||
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",
|
||||||
|
driver: "mysql2",
|
||||||
|
dbCredentials: {
|
||||||
|
// Read from env at runtime when using drizzle-kit
|
||||||
|
url: process.env.ZXDB_URL!,
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
7
example.env
Normal file
7
example.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# ZXDB MySQL connection URL
|
||||||
|
# Example using a readonly user created by ZXDB scripts
|
||||||
|
# CREATE ROLE 'zxdb_readonly';
|
||||||
|
# GRANT SELECT, SHOW VIEW ON `zxdb`.* TO 'zxdb_readonly';
|
||||||
|
# See docs/ZXDB.md for full setup instructions (DB import, helper tables,
|
||||||
|
# readonly role, and environment validation notes).
|
||||||
|
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
|
||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-explorer",
|
"name": "next-explorer",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=4000 next dev --turbopack",
|
"dev": "PORT=4000 next dev --turbopack",
|
||||||
@@ -12,20 +12,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"drizzle-orm": "^0.36.1",
|
||||||
|
"mysql2": "^3.12.0",
|
||||||
"next": "~15.5.7",
|
"next": "~15.5.7",
|
||||||
"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.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@types/node": "^20.19.25",
|
"@types/node": "^20.19.25",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"drizzle-kit": "^0.30.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "15.5.4",
|
"eslint-config-next": "15.5.4",
|
||||||
"sass": "^1.94.2"
|
"sass": "^1.63.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1022
pnpm-lock.yaml
generated
1022
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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";
|
||||||
31
src/app/api/zxdb/genres/[id]/route.ts
Normal file
31
src/app/api/zxdb/genres/[id]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { entriesByGenre } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
const querySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().optional(),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
|
if (!p.success) {
|
||||||
|
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = querySchema.safeParse({
|
||||||
|
page: searchParams.get("page") ?? undefined,
|
||||||
|
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!q.success) {
|
||||||
|
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
|
||||||
|
}
|
||||||
|
const page = q.data.page ?? 1;
|
||||||
|
const pageSize = q.data.pageSize ?? 20;
|
||||||
|
const data = await entriesByGenre(p.data.id, page, pageSize);
|
||||||
|
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
13
src/app/api/zxdb/genres/route.ts
Normal file
13
src/app/api/zxdb/genres/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { listGenres } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const data = await listGenres();
|
||||||
|
return new Response(JSON.stringify({ items: data }), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
51
src/app/api/zxdb/labels/[id]/route.ts
Normal file
51
src/app/api/zxdb/labels/[id]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
const querySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().optional(),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
|
if (!p.success) {
|
||||||
|
return new Response(JSON.stringify({ error: p.error.flatten() }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = querySchema.safeParse({
|
||||||
|
page: searchParams.get("page") ?? undefined,
|
||||||
|
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!q.success) {
|
||||||
|
return new Response(JSON.stringify({ error: q.error.flatten() }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const id = p.data.id;
|
||||||
|
const label = await getLabelById(id);
|
||||||
|
if (!label) {
|
||||||
|
return new Response(JSON.stringify({ error: "Not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const page = q.data.page ?? 1;
|
||||||
|
const pageSize = q.data.pageSize ?? 20;
|
||||||
|
const [authored, published] = await Promise.all([
|
||||||
|
getLabelAuthoredEntries(id, { page, pageSize }),
|
||||||
|
getLabelPublishedEntries(id, { page, pageSize }),
|
||||||
|
]);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ label, authored, published }),
|
||||||
|
{ headers: { "content-type": "application/json", "cache-control": "no-store" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
30
src/app/api/zxdb/labels/search/route.ts
Normal file
30
src/app/api/zxdb/labels/search/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { searchLabels } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().optional(),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const parsed = querySchema.safeParse({
|
||||||
|
q: searchParams.get("q") ?? undefined,
|
||||||
|
page: searchParams.get("page") ?? undefined,
|
||||||
|
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: parsed.error.flatten() }),
|
||||||
|
{ status: 400, headers: { "content-type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await searchLabels(parsed.data);
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
31
src/app/api/zxdb/languages/[id]/route.ts
Normal file
31
src/app/api/zxdb/languages/[id]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { entriesByLanguage } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({ id: z.string().trim().length(2) });
|
||||||
|
const querySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().optional(),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
|
if (!p.success) {
|
||||||
|
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = querySchema.safeParse({
|
||||||
|
page: searchParams.get("page") ?? undefined,
|
||||||
|
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!q.success) {
|
||||||
|
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
|
||||||
|
}
|
||||||
|
const page = q.data.page ?? 1;
|
||||||
|
const pageSize = q.data.pageSize ?? 20;
|
||||||
|
const data = await entriesByLanguage(p.data.id, page, pageSize);
|
||||||
|
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
13
src/app/api/zxdb/languages/route.ts
Normal file
13
src/app/api/zxdb/languages/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { listLanguages } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const data = await listLanguages();
|
||||||
|
return new Response(JSON.stringify({ items: data }), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
31
src/app/api/zxdb/machinetypes/[id]/route.ts
Normal file
31
src/app/api/zxdb/machinetypes/[id]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { entriesByMachinetype } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
const querySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().optional(),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
|
if (!p.success) {
|
||||||
|
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = querySchema.safeParse({
|
||||||
|
page: searchParams.get("page") ?? undefined,
|
||||||
|
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!q.success) {
|
||||||
|
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
|
||||||
|
}
|
||||||
|
const page = q.data.page ?? 1;
|
||||||
|
const pageSize = q.data.pageSize ?? 20;
|
||||||
|
const data = await entriesByMachinetype(p.data.id, page, pageSize);
|
||||||
|
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
13
src/app/api/zxdb/machinetypes/route.ts
Normal file
13
src/app/api/zxdb/machinetypes/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { listMachinetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const data = await listMachinetypes();
|
||||||
|
return new Response(JSON.stringify({ items: data }), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
48
src/app/api/zxdb/search/route.ts
Normal file
48
src/app/api/zxdb/search/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { searchEntries, getEntryFacets } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().optional(),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
|
genreId: z.coerce.number().int().positive().optional(),
|
||||||
|
languageId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.length(2, "languageId must be a 2-char code")
|
||||||
|
.optional(),
|
||||||
|
machinetypeId: z.coerce.number().int().positive().optional(),
|
||||||
|
sort: z.enum(["title", "id_desc"]).optional(),
|
||||||
|
facets: z.coerce.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const parsed = querySchema.safeParse({
|
||||||
|
q: searchParams.get("q") ?? undefined,
|
||||||
|
page: searchParams.get("page") ?? undefined,
|
||||||
|
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||||
|
genreId: searchParams.get("genreId") ?? undefined,
|
||||||
|
languageId: searchParams.get("languageId") ?? undefined,
|
||||||
|
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
||||||
|
sort: searchParams.get("sort") ?? undefined,
|
||||||
|
facets: searchParams.get("facets") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: parsed.error.flatten() }),
|
||||||
|
{ status: 400, headers: { "content-type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await searchEntries(parsed.data);
|
||||||
|
const body = parsed.data.facets
|
||||||
|
? { ...data, facets: await getEntryFacets(parsed.data) }
|
||||||
|
: data;
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Node.js runtime (required for mysql2)
|
||||||
|
export const runtime = "nodejs";
|
||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import RegisterDetail from '@/app/registers/RegisterDetail';
|
|||||||
import {Container, Row} from "react-bootstrap";
|
import {Container, Row} from "react-bootstrap";
|
||||||
import { getRegisters } from '@/services/register.service';
|
import { getRegisters } from '@/services/register.service';
|
||||||
|
|
||||||
export default async function RegisterDetailPage({ params }: { params: { hex: string } }) {
|
export default async function RegisterDetailPage({ params }: { params: Promise<{ hex: string }> }) {
|
||||||
const registers = await getRegisters();
|
const registers = await getRegisters();
|
||||||
const targetHex = decodeURIComponent((await params).hex).toLowerCase();
|
const { hex } = await params;
|
||||||
|
const targetHex = decodeURIComponent(hex).toLowerCase();
|
||||||
|
|
||||||
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);
|
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);
|
||||||
|
|
||||||
|
|||||||
259
src/app/zxdb/ZxdbExplorer.tsx
Normal file
259
src/app/zxdb/ZxdbExplorer.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
isXrated: number;
|
||||||
|
machinetypeId: number | null;
|
||||||
|
machinetypeName?: string | null;
|
||||||
|
languageId: string | null;
|
||||||
|
languageName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Paged<T> = {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ZxdbExplorer({
|
||||||
|
initial,
|
||||||
|
initialGenres,
|
||||||
|
initialLanguages,
|
||||||
|
initialMachines,
|
||||||
|
}: {
|
||||||
|
initial?: Paged<Item>;
|
||||||
|
initialGenres?: { id: number; name: string }[];
|
||||||
|
initialLanguages?: { id: string; name: string }[];
|
||||||
|
initialMachines?: { id: number; name: string }[];
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [page, setPage] = useState(initial?.page ?? 1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
|
||||||
|
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
|
||||||
|
const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []);
|
||||||
|
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []);
|
||||||
|
const [genreId, setGenreId] = useState<number | "">("");
|
||||||
|
const [languageId, setLanguageId] = useState<string | "">("");
|
||||||
|
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
|
||||||
|
const [sort, setSort] = useState<"title" | "id_desc">("id_desc");
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
|
async function fetchData(query: string, p: number) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query) params.set("q", query);
|
||||||
|
params.set("page", String(p));
|
||||||
|
params.set("pageSize", String(pageSize));
|
||||||
|
if (genreId !== "") params.set("genreId", String(genreId));
|
||||||
|
if (languageId !== "") params.set("languageId", String(languageId));
|
||||||
|
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
|
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||||
|
const json: Paged<Item> = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(e);
|
||||||
|
setData({ items: [], page: 1, pageSize, total: 0 });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// When navigating via Next.js Links that change ?page=, SSR provides new `initial`.
|
||||||
|
// Sync local state from new SSR payload so the list and counter update immediately
|
||||||
|
// without an extra client fetch.
|
||||||
|
if (initial) {
|
||||||
|
setData(initial);
|
||||||
|
setPage(initial.page);
|
||||||
|
}
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Avoid immediate client fetch on first paint if server provided initial data for this exact state
|
||||||
|
const initialPage = initial?.page ?? 1;
|
||||||
|
if (
|
||||||
|
initial &&
|
||||||
|
page === initialPage &&
|
||||||
|
q === "" &&
|
||||||
|
genreId === "" &&
|
||||||
|
languageId === "" &&
|
||||||
|
machinetypeId === "" &&
|
||||||
|
sort === "id_desc"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchData(q, page);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, genreId, languageId, machinetypeId, sort]);
|
||||||
|
|
||||||
|
// Load filter lists on mount only if not provided by server
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialGenres && initialLanguages && initialMachines) return;
|
||||||
|
async function loadLists() {
|
||||||
|
try {
|
||||||
|
const [g, l, m] = await Promise.all([
|
||||||
|
fetch("/api/zxdb/genres", { cache: "force-cache" }).then((r) => r.json()),
|
||||||
|
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
|
||||||
|
fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
setGenres(g.items ?? []);
|
||||||
|
setLanguages(l.items ?? []);
|
||||||
|
setMachines(m.items ?? []);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
loadLists();
|
||||||
|
}, [initialGenres, initialLanguages, initialMachines]);
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(1);
|
||||||
|
fetchData(q, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-3">ZXDB Explorer</h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Search titles..."
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={genreId as any} 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 as any} 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 as any} onChange={(e) => setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value))}>
|
||||||
|
<option value="">Machine</option>
|
||||||
|
{machines.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as any)}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
182
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
|
export type EntryDetailData = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
isXrated: number;
|
||||||
|
machinetype: { id: number | null; name: string | null };
|
||||||
|
language: { id: string | null; name: string | null };
|
||||||
|
genre: { id: number | null; name: string | null };
|
||||||
|
authors: Label[];
|
||||||
|
publishers: Label[];
|
||||||
|
// extra fields for richer details
|
||||||
|
maxPlayers?: number;
|
||||||
|
availabletypeId?: string | null;
|
||||||
|
withoutLoadScreen?: number;
|
||||||
|
withoutInlay?: number;
|
||||||
|
issueId?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
|
||||||
|
if (!data) return <div className="alert alert-warning">Not found</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="mb-0">{data.title}</h1>
|
||||||
|
{data.genre.name && (
|
||||||
|
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
|
||||||
|
{data.genre.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{data.language.name && (
|
||||||
|
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
|
||||||
|
{data.language.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{data.machinetype.name && (
|
||||||
|
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
|
||||||
|
{data.machinetype.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 220 }}>Field</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>ID</td>
|
||||||
|
<td>{data.id}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Title</td>
|
||||||
|
<td>{data.title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Machine</td>
|
||||||
|
<td>
|
||||||
|
{data.machinetype.id != null ? (
|
||||||
|
data.machinetype.name ? (
|
||||||
|
<Link href={`/zxdb/machinetypes/${data.machinetype.id}`}>{data.machinetype.name}</Link>
|
||||||
|
) : (
|
||||||
|
<span>#{data.machinetype.id}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Language</td>
|
||||||
|
<td>
|
||||||
|
{data.language.id ? (
|
||||||
|
data.language.name ? (
|
||||||
|
<Link href={`/zxdb/languages/${data.language.id}`}>{data.language.name}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{data.language.id}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Genre</td>
|
||||||
|
<td>
|
||||||
|
{data.genre.id ? (
|
||||||
|
data.genre.name ? (
|
||||||
|
<Link href={`/zxdb/genres/${data.genre.id}`}>{data.genre.name}</Link>
|
||||||
|
) : (
|
||||||
|
<span>#{data.genre.id}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{typeof data.maxPlayers !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<td>Max Players</td>
|
||||||
|
<td>{data.maxPlayers}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.availabletypeId !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<td>Available Type</td>
|
||||||
|
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.withoutLoadScreen !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<td>Without Load Screen</td>
|
||||||
|
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.withoutInlay !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<td>Without Inlay</td>
|
||||||
|
<td>{data.withoutInlay ? "Yes" : "No"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{typeof data.issueId !== "undefined" && (
|
||||||
|
<tr>
|
||||||
|
<td>Issue</td>
|
||||||
|
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<h5>Authors</h5>
|
||||||
|
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||||
|
{data.authors.length > 0 && (
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{data.authors.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<h5>Publishers</h5>
|
||||||
|
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||||
|
{data.publishers.length > 0 && (
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{data.publishers.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
|
||||||
|
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/zxdb/entries/[id]/page.tsx
Normal file
16
src/app/zxdb/entries/[id]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import EntryDetailClient from "./EntryDetail";
|
||||||
|
import { getEntryById } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "ZXDB Entry",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numericId = Number(id);
|
||||||
|
const data = await getEntryById(numericId);
|
||||||
|
// For simplicity, let the client render a Not Found state if null
|
||||||
|
return <EntryDetailClient data={data as any} />;
|
||||||
|
}
|
||||||
21
src/app/zxdb/genres/GenreList.tsx
Normal file
21
src/app/zxdb/genres/GenreList.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Genre = { id: number; name: string };
|
||||||
|
|
||||||
|
export default function GenreList({ items }: { items: Genre[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Genres</h1>
|
||||||
|
<ul className="list-group">
|
||||||
|
{items.map((g) => (
|
||||||
|
<li key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
|
||||||
|
<span className="badge text-bg-light">#{g.id}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/app/zxdb/genres/GenresSearch.tsx
Normal file
91
src/app/zxdb/genres/GenresSearch.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Genre = { id: number; name: string };
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Genre>; initialQ?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const [data, setData] = useState<Paged<Genre> | null>(initial ?? null);
|
||||||
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) setData(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQ(initialQ ?? "");
|
||||||
|
}, [initialQ]);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
params.set("page", "1");
|
||||||
|
router.push(`/zxdb/genres?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Genres</h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>}
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 120 }}>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((g) => (
|
||||||
|
<tr key={g.id}>
|
||||||
|
<td><span className="badge text-bg-light">#{g.id}</span></td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page <= 1}
|
||||||
|
href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page >= totalPages}
|
||||||
|
href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/zxdb/genres/[id]/GenreDetail.tsx
Normal file
93
src/app/zxdb/genres/[id]/GenreDetail.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-0">Genre <span className="badge text-bg-light">#{id}</span></h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder="Search within this genre…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
|
{initial && initial.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 80 }}>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Machine</th>
|
||||||
|
<th style={{ width: 120 }}>Language</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initial.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td>{it.id}</td>
|
||||||
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
|
<td>
|
||||||
|
{it.machinetypeId != null ? (
|
||||||
|
it.machinetypeName ? (
|
||||||
|
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{it.machinetypeId}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{it.languageId ? (
|
||||||
|
it.languageName ? (
|
||||||
|
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{it.languageId}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span>Page {initial.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page <= 1}
|
||||||
|
href={`/zxdb/genres/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page >= totalPages}
|
||||||
|
href={`/zxdb/genres/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/zxdb/genres/[id]/page.tsx
Normal file
16
src/app/zxdb/genres/[id]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import GenreDetailClient from "./GenreDetail";
|
||||||
|
import { entriesByGenre } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = { title: "ZXDB Genre" };
|
||||||
|
|
||||||
|
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
|
const numericId = Number(id);
|
||||||
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||||
|
const initial = await entriesByGenre(numericId, page, 20, q || undefined);
|
||||||
|
return <GenreDetailClient id={numericId} initial={initial as any} 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 as any} initialQ={q} />;
|
||||||
|
}
|
||||||
97
src/app/zxdb/labels/LabelsSearch.tsx
Normal file
97
src/app/zxdb/labels/LabelsSearch.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<Label>; initialQ?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const [data, setData] = useState<Paged<Label> | null>(initial ?? null);
|
||||||
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
|
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) setData(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
// Keep input in sync with URL q on navigation
|
||||||
|
useEffect(() => {
|
||||||
|
setQ(initialQ ?? "");
|
||||||
|
}, [initialQ]);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
params.set("page", "1");
|
||||||
|
router.push(`/zxdb/labels?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Labels</h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 100 }}>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style={{ width: 120 }}>Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>#{l.id}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page <= 1}
|
||||||
|
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page >= totalPages}
|
||||||
|
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
121
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
|
||||||
|
|
||||||
|
export default function LabelDetailClient({ id, initial, initialTab, initialQ }: { id: number; initial: Payload; initialTab?: "authored" | "published"; initialQ?: string }) {
|
||||||
|
// Keep only interactive UI state (tab). Data should come directly from SSR props so it updates on navigation.
|
||||||
|
const [tab, setTab] = useState<"authored" | "published">(initialTab ?? "authored");
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const router = useRouter();
|
||||||
|
// Names are now delivered by SSR payload to minimize pop-in.
|
||||||
|
|
||||||
|
if (!initial || !initial.label) return <div className="alert alert-warning">Not found</div>;
|
||||||
|
|
||||||
|
const current = useMemo(() => (tab === "authored" ? initial.authored : initial.published), [initial, tab]);
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<h1 className="mb-0">{initial.label.name}</h1>
|
||||||
|
<div>
|
||||||
|
<span className="badge text-bg-light">{initial.label.labeltypeId ?? "?"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="nav nav-tabs mt-3">
|
||||||
|
<li className="nav-item">
|
||||||
|
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button className={`nav-link ${tab === "published" ? "active" : ""}`} onClick={() => setTab("published")}>Published</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder={`Search within ${tab}…`} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
|
{current && current.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 80 }}>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Machine</th>
|
||||||
|
<th style={{ width: 120 }}>Language</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{current.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td>{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 {current.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${current.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={current.page <= 1}
|
||||||
|
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.page - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${current.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={current.page >= totalPages}
|
||||||
|
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.page + 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 as any, authored: authored as any, published: published as any }} 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 as any} initialQ={q} />;
|
||||||
|
}
|
||||||
21
src/app/zxdb/languages/LanguageList.tsx
Normal file
21
src/app/zxdb/languages/LanguageList.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Language = { id: string; name: string };
|
||||||
|
|
||||||
|
export default function LanguageList({ items }: { items: Language[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Languages</h1>
|
||||||
|
<ul className="list-group">
|
||||||
|
{items.map((l) => (
|
||||||
|
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
|
||||||
|
<span className="badge text-bg-light">{l.id}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/app/zxdb/languages/LanguagesSearch.tsx
Normal file
91
src/app/zxdb/languages/LanguagesSearch.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Language = { id: string; name: string };
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged<Language>; initialQ?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const [data, setData] = useState<Paged<Language> | null>(initial ?? null);
|
||||||
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) setData(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQ(initialQ ?? "");
|
||||||
|
}, [initialQ]);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
params.set("page", "1");
|
||||||
|
router.push(`/zxdb/languages?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Languages</h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>}
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 120 }}>Code</th>
|
||||||
|
<th>Name</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td><span className="badge text-bg-light">{l.id}</span></td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page <= 1}
|
||||||
|
href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page >= totalPages}
|
||||||
|
href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/zxdb/languages/[id]/LanguageDetail.tsx
Normal file
93
src/app/zxdb/languages/[id]/LanguageDetail.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: Paged<Item>; initialQ?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-0">Language <span className="badge text-bg-light">{id}</span></h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder="Search within this language…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
|
{initial && initial.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 80 }}>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Machine</th>
|
||||||
|
<th style={{ width: 120 }}>Language</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initial.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td>{it.id}</td>
|
||||||
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
|
<td>
|
||||||
|
{it.machinetypeId != null ? (
|
||||||
|
it.machinetypeName ? (
|
||||||
|
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{it.machinetypeId}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{it.languageId ? (
|
||||||
|
it.languageName ? (
|
||||||
|
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{it.languageId}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span>Page {initial.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page <= 1}
|
||||||
|
href={`/zxdb/languages/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page >= totalPages}
|
||||||
|
href={`/zxdb/languages/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/zxdb/languages/[id]/page.tsx
Normal file
15
src/app/zxdb/languages/[id]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import LanguageDetailClient from "./LanguageDetail";
|
||||||
|
import { entriesByLanguage } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = { title: "ZXDB Language" };
|
||||||
|
|
||||||
|
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||||
|
const initial = await entriesByLanguage(id, page, 20, q || undefined);
|
||||||
|
return <LanguageDetailClient id={id} initial={initial as any} 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 as any} initialQ={q} />;
|
||||||
|
}
|
||||||
21
src/app/zxdb/machinetypes/MachineTypeList.tsx
Normal file
21
src/app/zxdb/machinetypes/MachineTypeList.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type MT = { id: number; name: string };
|
||||||
|
|
||||||
|
export default function MachineTypeList({ items }: { items: MT[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Machine Types</h1>
|
||||||
|
<ul className="list-group">
|
||||||
|
{items.map((m) => (
|
||||||
|
<li key={m.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
|
||||||
|
<span className="badge text-bg-light">#{m.id}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal file
93
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type MT = { id: number; name: string };
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged<MT>; initialQ?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const [data, setData] = useState<Paged<MT> | null>(initial ?? null);
|
||||||
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
|
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) setData(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
// Keep input in sync with URL q on navigation
|
||||||
|
useEffect(() => {
|
||||||
|
setQ(initialQ ?? "");
|
||||||
|
}, [initialQ]);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
params.set("page", "1");
|
||||||
|
router.push(`/zxdb/machinetypes?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Machine Types</h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>}
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 120 }}>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td><span className="badge text-bg-light">#{m.id}</span></td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page <= 1}
|
||||||
|
href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page >= totalPages}
|
||||||
|
href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
Normal file
105
src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
isXrated: number;
|
||||||
|
machinetypeId: number | null;
|
||||||
|
machinetypeName?: string | null;
|
||||||
|
languageId: string | null;
|
||||||
|
languageName?: string | null;
|
||||||
|
};
|
||||||
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
|
export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
|
const machineName = useMemo(() => {
|
||||||
|
// Prefer the name already provided by SSR items to avoid client pop-in
|
||||||
|
return initial.items.find((it) => it.machinetypeId != null && it.machinetypeName)?.machinetypeName ?? null;
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-0">{machineName ?? "Machine Type"} <span className="badge text-bg-light">#{id}</span></h1>
|
||||||
|
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}>
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" placeholder="Search within this machine type…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
|
{initial && initial.items.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 80 }}>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Machine</th>
|
||||||
|
<th style={{ width: 120 }}>Language</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initial.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td>{it.id}</td>
|
||||||
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
|
<td>
|
||||||
|
{it.machinetypeId != null ? (
|
||||||
|
it.machinetypeName ? (
|
||||||
|
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{it.machinetypeId}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{it.languageId ? (
|
||||||
|
it.languageName ? (
|
||||||
|
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{it.languageId}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<span>Page {initial.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page <= 1}
|
||||||
|
href={`/zxdb/machinetypes/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page >= totalPages}
|
||||||
|
href={`/zxdb/machinetypes/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/zxdb/machinetypes/[id]/page.tsx
Normal file
15
src/app/zxdb/machinetypes/[id]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import MachineTypeDetailClient from "./MachineTypeDetail";
|
||||||
|
import { entriesByMachinetype } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = { title: "ZXDB Machine Type" };
|
||||||
|
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
|
const numericId = Number(id);
|
||||||
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||||
|
const initial = await entriesByMachinetype(numericId, page, 20, q || undefined);
|
||||||
|
return <MachineTypeDetailClient id={numericId} initial={initial as any} 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 as any} initialQ={q} />;
|
||||||
|
}
|
||||||
30
src/app/zxdb/page.tsx
Normal file
30
src/app/zxdb/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import ZxdbExplorer from "./ZxdbExplorer";
|
||||||
|
import { searchEntries, listGenres, listLanguages, listMachinetypes } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "ZXDB Explorer",
|
||||||
|
};
|
||||||
|
|
||||||
|
// This page depends on searchParams (?page=, filters in future). Force dynamic
|
||||||
|
// rendering so ISR doesn’t cache a single HTML for all query strings.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
// Server-render initial page based on URL to avoid first client fetch and keep counter in sync
|
||||||
|
const [initial, genres, langs, machines] = await Promise.all([
|
||||||
|
searchEntries({ page, pageSize: 20, sort: "id_desc" }),
|
||||||
|
listGenres(),
|
||||||
|
listLanguages(),
|
||||||
|
listMachinetypes(),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<ZxdbExplorer
|
||||||
|
initial={initial as any}
|
||||||
|
initialGenres={genres as any}
|
||||||
|
initialLanguages={langs as any}
|
||||||
|
initialMachines={machines as any}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export default function NavbarClient() {
|
|||||||
<Nav className="me-auto mb-2 mb-lg-0">
|
<Nav className="me-auto mb-2 mb-lg-0">
|
||||||
<Link className="nav-link" href="/">Home</Link>
|
<Link className="nav-link" href="/">Home</Link>
|
||||||
<Link className="nav-link" href="/registers">Registers</Link>
|
<Link className="nav-link" href="/registers">Registers</Link>
|
||||||
|
<Link className="nav-link" href="/zxdb">ZXDB</Link>
|
||||||
</Nav>
|
</Nav>
|
||||||
|
|
||||||
<ThemeDropdown />
|
<ThemeDropdown />
|
||||||
|
|||||||
33
src/env.ts
Normal file
33
src/env.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Server-side environment schema (t3.gg style)
|
||||||
|
const serverSchema = z.object({
|
||||||
|
// Full MySQL connection URL, e.g. mysql://user:pass@host:3306/zxdb
|
||||||
|
ZXDB_URL: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.refine((s) => s.startsWith("mysql://"), {
|
||||||
|
message: "ZXDB_URL must be a valid mysql:// URL",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatErrors(errors: z.ZodFormattedError<Map<string, string>, string>) {
|
||||||
|
return Object.entries(errors)
|
||||||
|
.map(([name, value]) => {
|
||||||
|
if (value && "_errors" in value) {
|
||||||
|
return `${name}: ${(value as any)._errors.join(", ")}`;
|
||||||
|
}
|
||||||
|
return `${name}: invalid`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = serverSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
// Fail fast with helpful output in server context
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
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;
|
||||||
751
src/server/repo/zxdb.ts
Normal file
751
src/server/repo/zxdb.ts
Normal file
@@ -0,0 +1,751 @@
|
|||||||
|
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import {
|
||||||
|
entries,
|
||||||
|
searchByTitles,
|
||||||
|
labels,
|
||||||
|
authors,
|
||||||
|
publishers,
|
||||||
|
languages,
|
||||||
|
machinetypes,
|
||||||
|
genretypes,
|
||||||
|
} from "@/server/schema/zxdb";
|
||||||
|
|
||||||
|
export interface SearchParams {
|
||||||
|
q?: string;
|
||||||
|
page?: number; // 1-based
|
||||||
|
pageSize?: number; // default 20
|
||||||
|
// Optional simple filters (ANDed together)
|
||||||
|
genreId?: number;
|
||||||
|
languageId?: string;
|
||||||
|
machinetypeId?: number;
|
||||||
|
// Sorting
|
||||||
|
sort?: "title" | "id_desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResultItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
isXrated: number;
|
||||||
|
machinetypeId: number | null;
|
||||||
|
machinetypeName?: string | null;
|
||||||
|
languageId: string | null;
|
||||||
|
languageName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacetItem<T extends number | string> {
|
||||||
|
id: T;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryFacets {
|
||||||
|
genres: FacetItem<number>[];
|
||||||
|
languages: FacetItem<string>[];
|
||||||
|
machinetypes: FacetItem<number>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> {
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const sort = params.sort ?? (q ? "title" : "id_desc");
|
||||||
|
|
||||||
|
if (q.length === 0) {
|
||||||
|
// Default listing: return first page by id desc (no guaranteed ordering field; using id)
|
||||||
|
// Apply optional filters even without q
|
||||||
|
const whereClauses = [
|
||||||
|
params.genreId ? eq(entries.genretypeId, params.genreId as any) : undefined,
|
||||||
|
params.languageId ? eq(entries.languageId, params.languageId as any) : undefined,
|
||||||
|
params.machinetypeId ? eq(entries.machinetypeId, params.machinetypeId as any) : undefined,
|
||||||
|
].filter(Boolean) as any[];
|
||||||
|
|
||||||
|
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
|
||||||
|
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(whereExpr as any)
|
||||||
|
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
db
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(entries)
|
||||||
|
.where(whereExpr as any) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
|
|
||||||
|
// Count matches via helper table
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
|
||||||
|
.from(searchByTitles)
|
||||||
|
.where(like(searchByTitles.entryTitle, pattern));
|
||||||
|
|
||||||
|
const total = Number(countRows[0]?.total ?? 0);
|
||||||
|
|
||||||
|
// Items using join to entries, distinct entry ids
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(searchByTitles)
|
||||||
|
.innerJoin(entries, eq(entries.id, searchByTitles.entryId))
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(like(searchByTitles.entryTitle, pattern))
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
labeltypeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryDetail {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
isXrated: number;
|
||||||
|
machinetype: { id: number | null; name: string | null };
|
||||||
|
language: { id: string | null; name: string | null };
|
||||||
|
genre: { id: number | null; name: string | null };
|
||||||
|
authors: LabelSummary[];
|
||||||
|
publishers: LabelSummary[];
|
||||||
|
// Additional entry fields for richer details
|
||||||
|
maxPlayers?: number;
|
||||||
|
availabletypeId?: string | null;
|
||||||
|
withoutLoadScreen?: number;
|
||||||
|
withoutInlay?: number;
|
||||||
|
issueId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
||||||
|
// Run base row + contributors in parallel to reduce latency
|
||||||
|
const [rows, authorRows, publisherRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
genreId: entries.genretypeId,
|
||||||
|
genreName: genretypes.name,
|
||||||
|
maxPlayers: entries.maxPlayers,
|
||||||
|
availabletypeId: entries.availabletypeId,
|
||||||
|
withoutLoadScreen: entries.withoutLoadScreen,
|
||||||
|
withoutInlay: entries.withoutInlay,
|
||||||
|
issueId: entries.issueId,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any))
|
||||||
|
.where(eq(entries.id, id)),
|
||||||
|
db
|
||||||
|
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||||
|
.from(authors)
|
||||||
|
.innerJoin(labels, eq(labels.id, authors.labelId))
|
||||||
|
.where(eq(authors.entryId, id))
|
||||||
|
.groupBy(labels.id),
|
||||||
|
db
|
||||||
|
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||||
|
.from(publishers)
|
||||||
|
.innerJoin(labels, eq(labels.id, publishers.labelId))
|
||||||
|
.where(eq(publishers.entryId, id))
|
||||||
|
.groupBy(labels.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const base = rows[0];
|
||||||
|
if (!base) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: base.id,
|
||||||
|
title: base.title,
|
||||||
|
isXrated: base.isXrated as any,
|
||||||
|
machinetype: { id: (base.machinetypeId as any) ?? null, name: (base.machinetypeName as any) ?? null },
|
||||||
|
language: { id: (base.languageId as any) ?? null, name: (base.languageName as any) ?? null },
|
||||||
|
genre: { id: (base.genreId as any) ?? null, name: (base.genreName as any) ?? null },
|
||||||
|
authors: authorRows as any,
|
||||||
|
publishers: publisherRows as any,
|
||||||
|
maxPlayers: (base.maxPlayers as any) ?? undefined,
|
||||||
|
availabletypeId: (base.availabletypeId as any) ?? undefined,
|
||||||
|
withoutLoadScreen: (base.withoutLoadScreen as any) ?? undefined,
|
||||||
|
withoutInlay: (base.withoutInlay as any) ?? undefined,
|
||||||
|
issueId: (base.issueId as any) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Labels -----
|
||||||
|
|
||||||
|
export interface LabelDetail extends LabelSummary {}
|
||||||
|
|
||||||
|
export interface LabelSearchParams {
|
||||||
|
q?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchLabels(params: LabelSearchParams): Promise<PagedResult<LabelSummary>> {
|
||||||
|
const q = (params.q ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||||
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset),
|
||||||
|
db
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(labels) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using helper search_by_names for efficiency
|
||||||
|
const pattern = `%${q}%`;
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${sql.identifier("label_id")})` })
|
||||||
|
.from(sql`search_by_names` as any)
|
||||||
|
.where(like(sql.identifier("label_name") as any, pattern));
|
||||||
|
const total = Number(countRows[0]?.total ?? 0);
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||||
|
.from(sql`search_by_names` as any)
|
||||||
|
.innerJoin(labels, eq(labels.id as any, sql.identifier("label_id") as any))
|
||||||
|
.where(like(sql.identifier("label_name") as any, pattern))
|
||||||
|
.groupBy(labels.id)
|
||||||
|
.orderBy(labels.name)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabelById(id: number): Promise<LabelDetail | null> {
|
||||||
|
const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1);
|
||||||
|
return (rows[0] as any) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelContribsParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
q?: string; // optional title filter
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
|
||||||
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const hasQ = !!(params.q && params.q.trim());
|
||||||
|
|
||||||
|
if (!hasQ) {
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${authors.entryId})` })
|
||||||
|
.from(authors)
|
||||||
|
.where(eq(authors.labelId, labelId));
|
||||||
|
const total = Number(countRows[0]?.total ?? 0);
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(authors)
|
||||||
|
.innerJoin(entries, eq(entries.id, authors.entryId))
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(eq(authors.labelId, labelId))
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${entries.id})` })
|
||||||
|
.from(authors)
|
||||||
|
.innerJoin(entries, eq(entries.id, authors.entryId))
|
||||||
|
.where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
|
||||||
|
const total = Number((countRows as any)[0]?.total ?? 0);
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(authors)
|
||||||
|
.innerJoin(entries, eq(entries.id, authors.entryId))
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabelPublishedEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
|
||||||
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const hasQ = !!(params.q && params.q.trim());
|
||||||
|
|
||||||
|
if (!hasQ) {
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${publishers.entryId})` })
|
||||||
|
.from(publishers)
|
||||||
|
.where(eq(publishers.labelId, labelId));
|
||||||
|
const total = Number(countRows[0]?.total ?? 0);
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(publishers)
|
||||||
|
.innerJoin(entries, eq(entries.id, publishers.entryId))
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(eq(publishers.labelId, labelId))
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${entries.id})` })
|
||||||
|
.from(publishers)
|
||||||
|
.innerJoin(entries, eq(entries.id, publishers.entryId))
|
||||||
|
.where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
|
||||||
|
const total = Number((countRows as any)[0]?.total ?? 0);
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(publishers)
|
||||||
|
.innerJoin(entries, eq(entries.id, publishers.entryId))
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Lookups lists and category browsing -----
|
||||||
|
|
||||||
|
export async function listGenres() {
|
||||||
|
return db.select().from(genretypes).orderBy(genretypes.name);
|
||||||
|
}
|
||||||
|
export async function listLanguages() {
|
||||||
|
return db.select().from(languages).orderBy(languages.name);
|
||||||
|
}
|
||||||
|
export async function listMachinetypes() {
|
||||||
|
return db.select().from(machinetypes).orderBy(machinetypes.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with pagination for lookups
|
||||||
|
export interface SimpleSearchParams {
|
||||||
|
q?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchLanguages(params: SimpleSearchParams) {
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db.select().from(languages).orderBy(languages.name).limit(pageSize).offset(offset),
|
||||||
|
db.select({ total: sql<number>`count(*)` }).from(languages) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q}%`;
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(languages)
|
||||||
|
.where(like(languages.name as any, pattern))
|
||||||
|
.orderBy(languages.name)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ total: sql<number>`count(*)` }).from(languages).where(like(languages.name as any, pattern)) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchGenres(params: SimpleSearchParams) {
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db.select().from(genretypes).orderBy(genretypes.name).limit(pageSize).offset(offset),
|
||||||
|
db.select({ total: sql<number>`count(*)` }).from(genretypes) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q}%`;
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(genretypes)
|
||||||
|
.where(like(genretypes.name as any, pattern))
|
||||||
|
.orderBy(genretypes.name)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ total: sql<number>`count(*)` }).from(genretypes).where(like(genretypes.name as any, pattern)) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchMachinetypes(params: SimpleSearchParams) {
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db.select().from(machinetypes).orderBy(machinetypes.name).limit(pageSize).offset(offset),
|
||||||
|
db.select({ total: sql<number>`count(*)` }).from(machinetypes) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q}%`;
|
||||||
|
const [items, countRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(machinetypes)
|
||||||
|
.where(like(machinetypes.name as any, pattern))
|
||||||
|
.orderBy(machinetypes.name)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ total: sql<number>`count(*)` }).from(machinetypes).where(like(machinetypes.name as any, pattern)) as unknown as Promise<{ total: number }[]>,
|
||||||
|
]);
|
||||||
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entriesByGenre(
|
||||||
|
genreId: number,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
q?: string
|
||||||
|
): Promise<PagedResult<SearchResultItem>> {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const hasQ = !!(q && q.trim());
|
||||||
|
|
||||||
|
if (!hasQ) {
|
||||||
|
const countRows = (await db
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(entries)
|
||||||
|
.where(eq(entries.genretypeId, genreId as any))) as unknown as { total: number }[];
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(eq(entries.genretypeId, genreId as any))
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${entries.id})` })
|
||||||
|
.from(entries)
|
||||||
|
.where(and(eq(entries.genretypeId, genreId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
|
||||||
|
const total = Number((countRows as any)[0]?.total ?? 0);
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(and(eq(entries.genretypeId, genreId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entriesByLanguage(
|
||||||
|
langId: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
q?: string
|
||||||
|
): Promise<PagedResult<SearchResultItem>> {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const hasQ = !!(q && q.trim());
|
||||||
|
|
||||||
|
if (!hasQ) {
|
||||||
|
const countRows = (await db
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(entries)
|
||||||
|
.where(eq(entries.languageId, langId as any))) as unknown as { total: number }[];
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(eq(entries.languageId, langId as any))
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${entries.id})` })
|
||||||
|
.from(entries)
|
||||||
|
.where(and(eq(entries.languageId, langId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
|
||||||
|
const total = Number((countRows as any)[0]?.total ?? 0);
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(and(eq(entries.languageId, langId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entriesByMachinetype(
|
||||||
|
mtId: number,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
q?: string
|
||||||
|
): Promise<PagedResult<SearchResultItem>> {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const hasQ = !!(q && q.trim());
|
||||||
|
|
||||||
|
if (!hasQ) {
|
||||||
|
const countRows = (await db
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(entries)
|
||||||
|
.where(eq(entries.machinetypeId, mtId as any))) as unknown as { total: number }[];
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(eq(entries.machinetypeId, mtId as any))
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
|
const countRows = await db
|
||||||
|
.select({ total: sql<number>`count(distinct ${entries.id})` })
|
||||||
|
.from(entries)
|
||||||
|
.where(and(eq(entries.machinetypeId, mtId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
|
||||||
|
const total = Number((countRows as any)[0]?.total ?? 0);
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
|
.where(and(eq(entries.machinetypeId, mtId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
return { items: items as any, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Facets for search -----
|
||||||
|
|
||||||
|
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
|
||||||
|
|
||||||
|
// Build base WHERE SQL snippet considering q + filters
|
||||||
|
const whereParts: any[] = [];
|
||||||
|
if (pattern) {
|
||||||
|
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||||
|
}
|
||||||
|
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
|
||||||
|
if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);
|
||||||
|
if (params.machinetypeId) whereParts.push(sql`${entries.machinetypeId} = ${params.machinetypeId}`);
|
||||||
|
|
||||||
|
const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts as any, sql` and `)], sql``) : sql``;
|
||||||
|
|
||||||
|
// Genres facet
|
||||||
|
const genresRows = await db.execute(sql`
|
||||||
|
select e.genretype_id as id, gt.text as name, count(*) as count
|
||||||
|
from ${entries} as e
|
||||||
|
left join ${genretypes} as gt on gt.id = e.genretype_id
|
||||||
|
${whereSql}
|
||||||
|
group by e.genretype_id, gt.text
|
||||||
|
order by count desc, name asc
|
||||||
|
`) as any;
|
||||||
|
|
||||||
|
// Languages facet
|
||||||
|
const langRows = await db.execute(sql`
|
||||||
|
select e.language_id as id, l.text as name, count(*) as count
|
||||||
|
from ${entries} as e
|
||||||
|
left join ${languages} as l on l.id = e.language_id
|
||||||
|
${whereSql}
|
||||||
|
group by e.language_id, l.text
|
||||||
|
order by count desc, name asc
|
||||||
|
`) as any;
|
||||||
|
|
||||||
|
// Machinetypes facet
|
||||||
|
const mtRows = await db.execute(sql`
|
||||||
|
select e.machinetype_id as id, m.text as name, count(*) as count
|
||||||
|
from ${entries} as e
|
||||||
|
left join ${machinetypes} as m on m.id = e.machinetype_id
|
||||||
|
${whereSql}
|
||||||
|
group by e.machinetype_id, m.text
|
||||||
|
order by count desc, name asc
|
||||||
|
`) as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
genres: (genresRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
|
||||||
|
languages: (langRows as any[]).map((r: any) => ({ id: String(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
|
||||||
|
machinetypes: (mtRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/server/schema/zxdb.ts
Normal file
81
src/server/schema/zxdb.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { mysqlTable, int, varchar, tinyint, char } from "drizzle-orm/mysql-core";
|
||||||
|
|
||||||
|
// Minimal subset needed for browsing/searching
|
||||||
|
export const entries = mysqlTable("entries", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
title: varchar("title", { length: 250 }).notNull(),
|
||||||
|
isXrated: tinyint("is_xrated").notNull(),
|
||||||
|
machinetypeId: tinyint("machinetype_id"),
|
||||||
|
maxPlayers: tinyint("max_players").notNull().default(1),
|
||||||
|
languageId: char("language_id", { length: 2 }),
|
||||||
|
genretypeSpotId: tinyint("spot_genretype_id"),
|
||||||
|
genretypeId: tinyint("genretype_id"),
|
||||||
|
availabletypeId: char("availabletype_id", { length: 1 }),
|
||||||
|
withoutLoadScreen: tinyint("without_load_screen").notNull(),
|
||||||
|
withoutInlay: tinyint("without_inlay").notNull(),
|
||||||
|
issueId: int("issue_id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper table created by ZXDB_help_search.sql
|
||||||
|
export const searchByTitles = mysqlTable("search_by_titles", {
|
||||||
|
entryTitle: varchar("entry_title", { length: 250 }).notNull(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Entry = typeof entries.$inferSelect;
|
||||||
|
|
||||||
|
// ZXDB labels (people/companies/teams)
|
||||||
|
export const labels = mysqlTable("labels", {
|
||||||
|
id: int("id").notNull().primaryKey(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
labeltypeId: char("labeltype_id", { length: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper table for names search
|
||||||
|
export const searchByNames = mysqlTable("search_by_names", {
|
||||||
|
labelName: varchar("label_name", { length: 100 }).notNull(),
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: entries by authors
|
||||||
|
export const searchByAuthors = mysqlTable("search_by_authors", {
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: entries by publishers
|
||||||
|
export const searchByPublishers = mysqlTable("search_by_publishers", {
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Relations tables
|
||||||
|
export const authors = mysqlTable("authors", {
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
teamId: int("team_id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const publishers = mysqlTable("publishers", {
|
||||||
|
entryId: int("entry_id").notNull(),
|
||||||
|
labelId: int("label_id").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lookups
|
||||||
|
export const languages = mysqlTable("languages", {
|
||||||
|
id: char("id", { length: 2 }).notNull().primaryKey(),
|
||||||
|
// Column name in DB is `text`; map to `name` property for app ergonomics
|
||||||
|
name: varchar("text", { length: 100 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const machinetypes = mysqlTable("machinetypes", {
|
||||||
|
id: tinyint("id").notNull().primaryKey(),
|
||||||
|
// Column name in DB is `text`
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const genretypes = mysqlTable("genretypes", {
|
||||||
|
id: tinyint("id").notNull().primaryKey(),
|
||||||
|
// Column name in DB is `text`
|
||||||
|
name: varchar("text", { length: 50 }).notNull(),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user