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
This commit is contained in:
48
AGENTS.md
48
AGENTS.md
@@ -4,9 +4,11 @@ This document provides an overview of the Next Explorer project, its structure,
|
||||
|
||||
## Project Overview
|
||||
|
||||
Next Explorer is a web application for browsing and exploring the registers of the Spectrum Next computer. It is built with Next.js (App Router), React, and TypeScript.
|
||||
Next Explorer is a web application for exploring the Spectrum Next ecosystem. It is built with Next.js (App Router), React, and TypeScript.
|
||||
|
||||
The application reads register data from `data/nextreg.txt`, parses it on the server, and displays it in a user-friendly interface. Users can search for specific registers and view their details, including per-register notes and source snippets.
|
||||
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
|
||||
|
||||
@@ -64,6 +66,17 @@ next-explorer/
|
||||
- `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.
|
||||
@@ -91,23 +104,34 @@ Comment what the code does, not what the agent has done. The documentation's pur
|
||||
|
||||
- **Server Components**:
|
||||
- `src/app/registers/page.tsx` and `src/app/registers/[hex]/page.tsx` are Server Components.
|
||||
- They call `getRegisters()` on the server and pass the resulting data down to client components as props.
|
||||
- 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.
|
||||
- Renders a list or grid of registers.
|
||||
- `RegisterDetail.tsx`:
|
||||
- Marked with `'use client'`.
|
||||
- Renders a single register with tabs for different access modes.
|
||||
- Uses a modal to show the original source lines for the register.
|
||||
- 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**:
|
||||
- `src/app/registers/[hex]/page.tsx`:
|
||||
- Resolves the `[hex]` URL segment.
|
||||
- Looks up the corresponding register by `hex_address`.
|
||||
- Calls `notFound()` when no matching register exists.
|
||||
- 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***
|
||||
|
||||
@@ -120,4 +144,8 @@ Comment what the code does, not what the agent has done. The documentation's pur
|
||||
- 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>
|
||||
- Sign-off commit message as Junie@<hostname>
|
||||
|
||||
### References
|
||||
|
||||
- ZXDB setup and API usage: `docs/ZXDB.md`
|
||||
@@ -1,28 +1,15 @@
|
||||
perf(zxdb): server-render index pages with ISR and initial data
|
||||
docs: add ZXDB guide; refresh README & AGENTS
|
||||
|
||||
Why
|
||||
- Reduce time-to-first-content on ZXDB index pages by eliminating the initial client-side fetch and enabling incremental static regeneration.
|
||||
Expand and update documentation to reflect the current app (Registers + ZXDB Explorer), with clear setup and usage instructions.
|
||||
|
||||
What
|
||||
- Main Explorer (/zxdb):
|
||||
- Server-renders first page of results and lookup lists (genres, languages, machinetypes) and passes them as initial props.
|
||||
- Keeps client interactivity for subsequent searches/filters.
|
||||
- Labels index (/zxdb/labels):
|
||||
- Server-renders first page of empty search and passes as initial props to skip the first fetch.
|
||||
- Category lists:
|
||||
- Genres (/zxdb/genres), Languages (/zxdb/languages), Machine Types (/zxdb/machinetypes) now server-render their lists and export revalidate=3600.
|
||||
- Refactored list components to accept server-provided items; removed on-mount fetching.
|
||||
- Links & prefetch:
|
||||
- Replaced remaining anchors with Next Link to enable prefetch where applicable.
|
||||
|
||||
Tech details
|
||||
- Added revalidate=3600 to the index pages for ISR.
|
||||
- Updated ZxdbExplorer to accept initial results and initial filter lists; skips first client fetch when initial props are present.
|
||||
- Updated LabelsSearch to accept initial payload and skip first fetch in default state.
|
||||
- Updated GenreList, LanguageList, MachineTypeList to be presentational components receiving items from server pages.
|
||||
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
|
||||
- Low-churn list APIs already emit Cache-Control for CDN; list pages now render instantly from server.
|
||||
- Further polish (breadcrumbs, facet counts UI) can build on this foundation without reintroducing initial network waits.
|
||||
- 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
|
||||
Signed-off-by: Junie@lucy.xalior.com
|
||||
|
||||
61
README.md
61
README.md
@@ -1,14 +1,13 @@
|
||||
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`
|
||||
- Real‑time filtering with query‑string deep links (e.g. `/registers?q=vram`)
|
||||
- Register Explorer: parsed from `data/nextreg.txt`, with real‑time search and deep links
|
||||
- ZXDB Explorer: a deep, cross‑linked browser for entries, labels, genres, languages, and machine types backed by a MySQL ZXDB instance
|
||||
- Bootstrap 5 theme with light/dark support
|
||||
|
||||
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:
|
||||
- `pnpm install`
|
||||
- Run in development (Turbopack, port 4000):
|
||||
@@ -26,11 +25,53 @@ Project scripts (package.json)
|
||||
- `deploy-test`: push to `test.explorer.specnext.dev`
|
||||
- `deploy-prod`: push to `explorer.specnext.dev`
|
||||
|
||||
Documentation
|
||||
- Docs index: `docs/index.md`
|
||||
- Getting Started: `docs/getting-started.md`
|
||||
- Architecture: `docs/architecture.md`
|
||||
- Register Explorer: `docs/registers.md`
|
||||
Routes
|
||||
- `/` — Home
|
||||
- `/registers` — Register Explorer
|
||||
- `/zxdb` — ZXDB Explorer (search + filters)
|
||||
- `/zxdb/entries/[id]` — Entry detail
|
||||
- `/zxdb/labels` and `/zxdb/labels/[id]` — Labels search and detail
|
||||
- `/zxdb/genres` and `/zxdb/genres/[id]` — Genres list and entries
|
||||
- `/zxdb/languages` and `/zxdb/languages/[id]` — Languages list and entries
|
||||
- `/zxdb/machinetypes` and `/zxdb/machinetypes/[id]` — Machine types list and entries
|
||||
|
||||
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
|
||||
- See `LICENSE.txt` for details.
|
||||
|
||||
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).
|
||||
@@ -2,4 +2,6 @@
|
||||
# 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
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function ZxdbExplorer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<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">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import GenreList from "./GenreList";
|
||||
import { listGenres } from "@/server/repo/zxdb";
|
||||
import GenresSearch from "./GenresSearch";
|
||||
import { searchGenres } from "@/server/repo/zxdb";
|
||||
|
||||
export const metadata = { title: "ZXDB Genres" };
|
||||
|
||||
export const revalidate = 3600;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
const items = await listGenres();
|
||||
return <GenreList items={items as any} />;
|
||||
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} />;
|
||||
}
|
||||
|
||||
79
src/app/zxdb/languages/LanguagesSearch.tsx
Normal file
79
src/app/zxdb/languages/LanguagesSearch.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"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 && (
|
||||
<ul className="list-group">
|
||||
{data.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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import LanguageList from "./LanguageList";
|
||||
import { listLanguages } from "@/server/repo/zxdb";
|
||||
import LanguagesSearch from "./LanguagesSearch";
|
||||
import { searchLanguages } from "@/server/repo/zxdb";
|
||||
|
||||
export const metadata = { title: "ZXDB Languages" };
|
||||
|
||||
export const revalidate = 3600;
|
||||
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
const items = await listLanguages();
|
||||
return <LanguageList items={items as any} />;
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -311,6 +311,103 @@ 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): Promise<PagedResult<SearchResultItem>> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const countRows = (await db
|
||||
|
||||
Reference in New Issue
Block a user