83 Commits

Author SHA1 Message Date
fe1dfa4170 feat: enrich tape identifier results with entry details
Show authors, genre, machine type, release year, CRC32, and a
prominent "View entry" link. Joins releases, genretypes, machinetypes,
and authors in lookupByMd5() for richer context.

opus-4-6@McFiver
2026-02-17 16:38:34 +00:00
5f84f482ab docs: update WIP tracker — implementation complete
opus-4-6@McFiver
2026-02-17 16:35:14 +00:00
8624050614 feat: add tape identifier dropzone on /zxdb
Client computes MD5 + size in-browser, server action looks up
software_hashes to identify tape files against ZXDB entries.

- src/utils/md5.ts: pure-JS MD5 for browser (Web Crypto lacks MD5)
- src/app/zxdb/actions.ts: server action identifyTape()
- src/app/zxdb/TapeIdentifier.tsx: dropzone client component
- src/server/repo/zxdb.ts: lookupByMd5() joins hashes→downloads→entries
- src/app/zxdb/page.tsx: mount TapeIdentifier between hero and nav grid

opus-4-6@McFiver
2026-02-17 16:34:48 +00:00
fc513c580b wip: start tape identifier — init progress tracker
opus-4-6@McFiver
2026-02-17 16:32:22 +00:00
e27a16eda1 Ready to add F/E 2026-02-17 16:30:13 +00:00
5a6c536283 feat: add .o tape extension, recompute missing (32,960 rows)
claude-opus-4-6@MacFiver
2026-02-17 16:18:51 +00:00
6b91fde972 fix: silently skip /denied/ and other non-hosted download prefixes
These are valid entries we've been asked not to host — no need to
log warnings for them.

claude-opus-4-6@MacFiver
2026-02-17 16:17:43 +00:00
9efedb5f2e feat: add .p tape extension and --rebuild-missing flag
- Add .p (ZX81 program) to tape file extensions list
- Add --rebuild-missing flag: only processes downloads not yet in
  software_hashes (LEFT JOIN exclusion), avoids recomputing known hashes
- Recomputed: 639 new .p file hashes, total now 32,944 rows

claude-opus-4-6@MacFiver
2026-02-17 16:15:53 +00:00
51e1f10417 docs: update WIP tracker — implementation complete
claude-opus-4-6@MacFiver
2026-02-17 16:10:11 +00:00
9bfebc1372 feat: add initial software_hashes JSON snapshot (32,305 rows)
First full run of update-software-hashes.mjs completed:
- 32,305 tape-image downloads hashed (MD5, CRC32, size, inner path)
- Snapshot at data/zxdb/software_hashes.json for DB wipe recovery

claude-opus-4-6@MacFiver
2026-02-17 16:09:55 +00:00
edc937ad5d feat: add update-software-hashes.mjs pipeline script
Processes tape-image downloads (filetype_id 8, 22), extracts zip
contents, finds the inner tape file (.tap/.tzx/.pzx/.csw), computes
MD5/CRC32/size, and upserts into software_hashes table. Exports a
JSON snapshot for DB wipe recovery. Supports --resume, --rebuild-all,
--start-from-id, --export-only flags with state file persistence.

claude-opus-4-6@MacFiver
2026-02-17 16:07:04 +00:00
f5ae89e888 feat: add software_hashes table schema and reimport pipeline
- Add softwareHashes Drizzle model (download_id PK, md5, crc32,
  size_bytes, inner_path, updated_at)
- Update import_mysql.sh to reimport from JSON snapshot after DB wipe
- Add pnpm scripts: update:hashes, export:hashes
- Create data/zxdb/ directory for JSON snapshot storage

claude-opus-4-6@MacFiver
2026-02-17 16:06:51 +00:00
944a2dc4d1 wip: start feature/software-hashes — init progress tracker
claude-opus-4-6@MacFiver
2026-02-17 16:00:51 +00:00
b361201cf2 Ready to start adding hashes 2026-02-17 15:53:42 +00:00
b158bfc4a0 Improve ZXDB downloads with local mirroring and inline preview
This commit implements a comprehensive local file mirror system for
ZXDB and WoS downloads, allowing users to access local archives
directly through the explorer UI.

Key Changes:

Local File Mirroring & Proxy:
- Added `ZXDB_LOCAL_FILEPATH` and `WOS_LOCAL_FILEPATH` to `src/env.ts`
  and `example.env` for opt-in local mirroring.
- Implemented `resolveLocalLink` in `src/server/repo/zxdb.ts` to map
  database `file_link` paths to local filesystem paths based on
  configurable prefixes.
- Created `src/app/api/zxdb/download/route.ts` to safely proxy local
  files, preventing path traversal and serving with appropriate
  `Content-Type` and `Content-Disposition`.
- Updated `docs/ZXDB.md` with setup instructions and resolution logic.

UI Enhancements & Grouping:
- Grouped downloads and scraps by type (e.g., Inlay, Game manual, Tape
  image) in `EntryDetail.tsx` and `ReleaseDetail.tsx` for better
  organization.
- Introduced `FileViewer.tsx` component to provide inline previews
  for supported formats (.txt, .nfo, .png, .jpg, .gif, .pdf).
- Added a "Preview" button for local mirrors of supported file types.
- Optimized download tables with badge-style links for local/remote
  sources.

Guideline Updates:
- Updated `AGENTS.md` to clarify commit message handling: edit or
  append to `COMMIT_EDITMSG` instead of overwriting.
- Re-emphasized testing rules: use `tsc --noEmit`, do not restart
  dev-server, and avoid `pnpm build` during development.

Signed-off-by: junie@lucy.xalior.com
2026-02-17 14:49:38 +00:00
728b36e45e Update AGENTS.md with improved commit message handling guidelines
- Specify that COMMIT_EDITMSG should be created or updated.
- Encourage editing the existing file to maintain context across multiple steps.

Signed-off: junie@lucy.xalior.com
2026-02-17 13:14:56 +00:00
f445aabcb4 Improve download viewer with grouping and inline previews
Group downloads and scraps by type in Entry and Release details

Add FileViewer component for .txt, .nfo, image, and PDF previews

Update download API to support inline view with correct MIME types

Signed-off-by: Junie@lucy.xalior.com
2026-02-17 12:50:58 +00:00
32985c33b9 Proxy local ZXDB/WoS mirror downloads through application API
- Created `src/app/api/zxdb/download/route.ts` to serve local files.
- Updated `resolveLocalLink` in `src/server/repo/zxdb.ts` to return
  API-relative URLs with `source` and `path` parameters.
- Encodes the relative subpath to ensure correct URL construction.
- Includes security checks in the API route to prevent path traversal.
- Updated `docs/ZXDB.md` to reflect the proxy mechanism.

Signed-off: junie@lucy.xalior.com
2026-02-17 12:43:26 +00:00
2e47b598c1 Remove default path prefixes for local mirroring
- Removed hardcoded defaults for ZXDB_FILE_PREFIX and WOS_FILE_PREFIX in
  resolveLocalLink.
- Updated docs/ZXDB.md and example.env to remove mentions of defaults.
- Default behavior is now no prefix stripping if variables are omitted.

Signed-off-by: Junie@lucy.xalior.com
2026-02-17 12:35:46 +00:00
ab7872b610 Update example.env with local mirror configurations
Signed-off: Junie@lucy.xalior.com
2026-02-17 12:34:47 +00:00
4b3d1ccc7b Update documentation for local ZXDB/WoS mirrors
- Documented .env variables: ZXDB_LOCAL_FILEPATH, WOS_LOCAL_FILEPATH, ZXDB_FILE_PREFIX, WOS_FILE_PREFIX.
- Explained path resolution logic (strip prefix, prepend local path).
- Added setup notes to docs/ZXDB.md and AGENTS.md.

Signed-off-by: junie@lucy.xalior.com
2026-02-17 12:34:11 +00:00
77b5e76a08 Correct local file path resolution for ZXDB/WoS mirrors
- Remove optional path prefix and prepend the required local string.
- Avoid hardcoded 'SC' or 'WoS' subdirectories in path mapping.
- Maintain binary state: show local link only if env var is set and file exists.

Signed-off: junie@McFiver.local
2026-02-17 12:30:55 +00:00
cbee214a6b This actually runs now :-o 2026-02-17 12:19:14 +00:00
53eb9a1501 Implement magazine reviews, label details, and year filtering
- Aggregate magazine references from all releases on the Entry detail page.
- Display country names and external links (Wikipedia/Website) on the Label detail page.
- Add a year filter to the ZXDB Explorer to search entries by release year.

Signed-off: junie@lucy.xalior.com
2026-02-17 12:03:36 +00:00
9807005305 Update homepage hero
Replace the minimal homepage with a two-column hero for ZXDB and NextReg.

Signed-off-by: codex@lucy.xalior.com
2026-01-11 13:42:52 +00:00
24e08ce7b9 Better zxdb landing page 2026-01-11 13:40:31 +00:00
00a13e3289 Fix explorer hook deps
Resolve hook dependency warnings in entries and releases explorers.

Signed-off-by: codex@lucy.xalior.com
2026-01-11 13:30:10 +00:00
2d4b1b2d5b Refactor registers sidebar
Use shared explorer layout and sidebar for the registers browser.

Signed-off-by: codex@lucy.xalior.com
2026-01-11 13:23:49 +00:00
79d161afe1 Share explorer sidebar components
Introduce reusable explorer layout, sidebar, chips, and multi-select components.

Signed-off-by: codex@lucy.xalior.com
2026-01-11 13:21:19 +00:00
8a9c5395bd Change label on machines we use by default 2026-01-11 13:06:44 +00:00
1e8925e631 Add multi-select machine filters
Replace machine dropdowns with multi-select chips and pass machine lists in queries.

Signed-off-by: codex@lucy.xalior.com
2026-01-11 13:04:41 +00:00
2f93ed1774 Fix facets filter aliasing
Use the correct SQL alias in entry facet filters.

Signed-off-by: codex@lucy.xalior.com
2026-01-11 12:37:56 +00:00
dc6db608cd uncouple breadcrumbs from search input widget 2026-01-11 12:35:22 +00:00
762d13be55 Favor Next machine types in search
Prefer Spectrum Next and +3 results when no machine filter is selected.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 23:44:02 +00:00
48d02adbed Hunno this isn't all the case search fixes required...
Manual fixes for a lot of case places..

-Dx
2026-01-10 23:34:02 +00:00
9bb0a18695 Update setup docs and scripts
Refresh setup docs, add ZXDB local setup script, and note deploy rules.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:52:27 +00:00
89d48edbd9 Add deploy helper script
Add a deploy script and npm commands, and include
Navbar updates as requested.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:39:03 +00:00
0b0dced512 Revamp entry detail layout
Restructure entry detail into a two-column layout with
summary/people cards and section cards on the right.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:32:55 +00:00
e94492eab6 Unify ZXDB list layouts
Apply sidebar filter layout to label/genre/language/machine
lists and restructure release detail into a two-column view.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:05:28 +00:00
6f7ffa899d Refresh releases and magazines UI
Apply sidebar filter layout and header summary to releases
and magazines list pages.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 21:56:46 +00:00
84dee2710c Add genre column to entries
Include genre data in entry search results and show it
in the entries table layout.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 21:53:31 +00:00
5130a72641 Add entry facets and links
Surface alias/origin facets, SSR facets on entries page,
fix facet query ambiguity, and document clickable links.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 19:21:46 +00:00
964b48abf1 Add entry ports and scores
Surface ports, remakes, scores, and notes on entry detail.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:26:34 +00:00
d9f55c3eb6 Add entry relations and tags
Show relations and tag membership sections on entry detail.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:23:58 +00:00
06ddeba9bb Polish origins and guidelines
Add issue/magazine links and ordering to entry origins,
and document preferred validation guidance.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:18:20 +00:00
fb206734db Add ZXDB origins and label types
Show entry origins data and display label type names
in label detail view.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:12:30 +00:00
e2f6aac856 Expand ZXDB entry data and search
Add entry release/license sections, label permissions/licenses,
expanded search scope (titles+aliases+origins), and home search.
Also include ZXDB submodule and update gitignore.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:04:04 +00:00
3e13da5552 Improve ZXDB releases list
Link release titles to release detail, add magref count
badges, and show other releases on release detail.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 17:46:57 +00:00
0594b34c62 Add ZXDB breadcrumbs and release places
Add ZXDB breadcrumbs on list/detail pages and group release
magazine references by issue for clearer Places view.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 17:35:36 +00:00
5d140a45a7 Enhance ZXDB releases and caching
Signed-off-by: codex@lucy.xalior.com
2026-01-10 17:05:37 +00:00
208a06c351 Adding more releases, and sorting SASS 2026-01-10 16:55:12 +00:00
686e057bb4 Add missing ZXDB ORM tables
Signed-off-by: codex@lucy.xalior.com
2026-01-10 16:44:02 +00:00
f629cd0ca8 Merge branch 'deploy' into dev 2026-01-10 16:30:58 +00:00
8083b1e5de Update example env 2026-01-10 16:30:54 +00:00
cabd0567f7 Add hostname details to env, for opengraph 2026-01-04 13:37:14 +00:00
31522acd04 missing files 2026-01-01 15:54:59 +00:00
4467ef98fd Add OG images for register pages
- generate per-register metadata and OG thumbnails
- honor register text line breaks and de-duplicate summary lines

Signed-off-by: Codex@lucy.xalior.com
2026-01-01 15:42:53 +00:00
6237ff86d0 A yolo test of codex 2026-01-01 15:42:22 +00:00
616d775303 Docs: refresh README and AGENTS with latest ZXDB features
Update documentation to reflect the expanded ZXDB Explorer coverage, including releases, magazines, and issues. Document new API endpoints and the graceful schema fallback mechanism in the repository.

Changes:
- README.md: Add /zxdb/releases, /zxdb/magazines, and /zxdb/issues/[id] routes; document /api/zxdb/releases/search and lookup endpoints; add note on information_schema table checks.
- AGENTS.md: Update project overview and structure to include releases, magazines, and issues; note schema capability checks in the repository description.

Signed-off-by: Junie@lucy.xalior.com
2025-12-18 19:43:16 +00:00
a1a04a89cf Adding the first stubs of the magazine browser 2025-12-18 13:10:58 +00:00
279abac91a Link entry_id across UI; surface aliases/webrefs on Entry\n\n- Add EntryLink component for /zxdb/entries/[id]\n- Use EntryLink in Entries, Releases, and Label detail tables\n- Extend Entry detail with Aliases and Web links sections\n- Add Drizzle schema for aliases, webrefs, websites; fetch in repo\n\nSigned-off-by: Junie@lucy\n 2025-12-17 22:32:13 +00:00
2bade1825c Add entry_id relationship links to Entries
- Introduce reusable EntryLink component
- Use EntryLink in Releases and Label detail tables
- Link both ID and title to /zxdb/entries/[id] for consistency

Signed-off-by: Junie@MacOS
2025-12-17 22:30:48 +00:00
07478b280c Fix build errors 2025-12-17 20:22:00 +00:00
89001f53da No explicit any 2025-12-17 20:10:00 +00:00
18cf0cc140 Merge branch 'feat/zxdb' into deploy 2025-12-17 19:55:59 +00:00
53a1821547 Remove topbar nav 2025-12-17 19:54:47 +00:00
24cb74ac14 version bump 2025-12-17 12:41:17 +00:00
363c8bc121 Update docs/ZXDB.md 2025-12-17 12:07:28 +00:00
038c60338b Update docs/ZXDB.md 2025-12-17 12:05:50 +00:00
f563b41792 ZXDB: Releases browser filters, schema lists, and fixes
- UI: Add /zxdb hub cards for Entries and Releases; implement Releases browser
  with URL‑synced filters (q, year, sort, DL language/machine, file/scheme/source/case, demo)
  and a paginated table (Entry ID, Title, Release #, Year).
- API: Add GET /api/zxdb/releases/search (Zod‑validated, Node runtime) supporting
  title, year, sort, and downloads‑based filters; return paged JSON.
- Repo: Rewrite searchReleases to Drizzle QB; correct ORDER BY on releases.release_year;
  implement EXISTS on downloads using explicit "from downloads as d"; return JSON‑safe rows.
- Schema: Align Drizzle models with ZXDB for releases/downloads; add lookups
  availabletypes, currencies, roletypes, and roles relation.
- API (lookups): Add GET /api/zxdb/{availabletypes,currencies,roletypes} for dropdowns.
- Stability: JSON‑clone SSR payloads before passing to Client Components to avoid
  RowDataPacket serialization errors.

Signed-off-by: Junie@lucy.xalior.com
2025-12-16 23:00:38 +00:00
fd4c0f8963 Show downloads even without releases rows
Add synthetic release groups in getEntryById so downloads
are displayed even when there are no matching rows in
`releases` for a given entry. Group by `release_seq`,
attach downloads, and sort groups for stable order.

This fixes cases like /zxdb/entries/1 where `downloads`
exist for the entry but `releases` is empty, resulting in
no downloads shown in the UI.

Signed-off-by: Junie@devbox
2025-12-16 21:47:17 +00:00
285c7da87c Handle missing ZXDB releases/downloads schema gracefully
Prevent runtime crashes when `releases`, `downloads`, or related lookup tables
(`releasetypes`, `schemetypes`, `sourcetypes`, `casetypes`) are absent in the
connected ZXDB MySQL database.

- Repo: gate releases/downloads queries behind a schema capability check using
  `information_schema.tables`; if missing, skip queries and return empty arrays.
- Keeps entry detail page functional on legacy/minimal DB exports while fully
  utilizing rich data when available.

Refs: runtime error "Table 'zxdb.releasetypes' doesn't exist"

Signed-off-by: Junie@quinn
2025-12-16 18:41:14 +00:00
761810901f Bump React 2025-12-14 11:29:34 +00:00
f507d51c61 Bump React 2025-12-14 11:29:16 +00:00
240936a850 Standardize ZXDB UI; add SSR search/tables
Unify the look and feel of all /zxdb pages and minimize client pop-in.

- Make all /zxdb pages full-width to match /explorer
- Convert Languages, Genres, Machine Types, and Labels lists to
  Bootstrap tables with table-striped and table-hover inside
  table-responsive wrappers
- Replace raw FK IDs with linked names via SSR repository joins
- Add scoped search boxes on detail pages (labels, genres, languages,
  machine types) with SSR filtering and pagination that preserves q/tab
- Keep explorer results consistent: show Machine/Language names with
  links, no client lookups required

This improves consistency, readability, and first paint stability across
the ZXDB section while keeping navigation fast and discoverable.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 16:58:50 +00:00
ddbf72ea52 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
2025-12-12 16:17:35 +00:00
3ef3a16bc0 Fix ZXDB pagination counters and navigation
Implement URL-driven pagination and correct total counts across ZXDB:
- Root /zxdb: SSR reads ?page; client syncs to SSR; Prev/Next as Links.
- Sub-index pages (genres, languages, machinetypes): parse ?page on server; use SSR props in clients; Prev/Next via Links.
- Labels browse (/zxdb/labels): dynamic SSR, reads ?q & ?page; typed count(*); client syncs to SSR; Prev/Next preserve q.
- Label detail (/zxdb/labels/[id]): tab-aware Prev/Next Links; counters from server.
- Repo: replace raw counts with typed Drizzle count(*) for reliable totals.

Signed-off-by: Junie <Junie@lucy.xalior.com>
2025-12-12 16:11:12 +00:00
54cfe4f175 perf(zxdb): server-render index pages with ISR and initial data
Why
- Reduce time-to-first-content on ZXDB index pages by eliminating the initial client-side fetch and enabling incremental static regeneration.

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.

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.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 15:31:10 +00:00
ad77b47117 chore: commit pending ZXDB explorer changes prior to index perf work
Context
- Housekeeping commit to capture all current ZXDB Explorer work before index-page performance optimizations.

Includes
- Server-rendered entry detail page with ISR and parallelized DB queries.
- Node runtime for ZXDB API routes and params validation updates for Next 15.
- ZXDB repository extensions (facets, label queries, category queries).
- Cross-linking and Link-based prefetch across ZXDB UI.
- Cache headers on low-churn list APIs.

Notes
- Follow-up commit will focus specifically on speeding up index pages via SSR initial data and ISR.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 15:25:35 +00:00
3fe6f980c6 feat: integrate ZXDB with Drizzle + deep explorer UI; fix Next 15 dynamic params; align ZXDB schema columns
End-to-end ZXDB integration with environment validation, Drizzle ORM MySQL
setup, typed repositories, Zod-validated API endpoints, and a deep, cross‑
linked Explorer UI under `/zxdb`. Also update dynamic route pages to the
Next.js 15 async `params` API and align ZXDB lookup table columns (`text` vs
`name`).

Summary
- Add t3.gg-style Zod environment validation and typed `env` access
- Wire Drizzle ORM to ZXDB (mysql2 pool, singleton) and minimal schemas
- Implement repositories for search, entry details, label browsing, and
  category listings (genres, languages, machinetypes)
- Expose a set of Next.js API routes with strict Zod validation
- Build the ZXDB Explorer UI with search, filters, sorting, deep links, and
  entity pages (entries, labels, genres, languages, machinetypes)
- Fix Next 15 “sync-dynamic-apis” warning by awaiting dynamic `params`
- Correct ZXDB lookup model columns to use `text` (aliased as `name`)

Details
Env & DB
- example.env: document `ZXDB_URL` with readonly role notes
- src/env.ts: Zod schema validates `ZXDB_URL` as `mysql://…`; fails fast on
  invalid env
- src/server/db.ts: create mysql2 pool from `ZXDB_URL`; export Drizzle instance
- drizzle.config.ts: drizzle-kit configuration (schema path, mysql2 driver)

Schema (Drizzle)
- src/server/schema/zxdb.ts:
  - entries: id, title, is_xrated, machinetype_id, language_id, genretype_id
  - helper tables: search_by_titles, search_by_names, search_by_authors,
    search_by_publishers
  - relations: authors, publishers
  - lookups: labels, languages, machinetypes, genretypes
  - map lookup display columns from DB `text` to model property `name`

Repository
- src/server/repo/zxdb.ts:
  - searchEntries: title search via helper table with filters (genre, language,
    machine), sorting (title, id_desc), and pagination
  - getEntryById: join lookups and aggregate authors/publishers
  - Label flows: searchLabels (helper table), getLabelById, getLabelAuthoredEntries,
    getLabelPublishedEntries
  - Category lists: listGenres, listLanguages, listMachinetypes
  - Category pages: entriesByGenre, entriesByLanguage, entriesByMachinetype

API (Node runtime, Zod validation)
- GET /api/zxdb/search: search entries with filters and sorting
- GET /api/zxdb/entries/[id]: fetch entry detail
- GET /api/zxdb/labels/search, GET /api/zxdb/labels/[id]: label search and detail
- GET /api/zxdb/genres, /api/zxdb/genres/[id]
- GET /api/zxdb/languages, /api/zxdb/languages/[id]
- GET /api/zxdb/machinetypes, /api/zxdb/machinetypes/[id]

UI (App Router)
- /zxdb: Explorer page with search box, filters (genre, language, machine), sort,
  paginated results & links to entries; quick browse links to hubs
- /zxdb/entries/[id]: entry detail client component shows title, badges
  (genre/lang/machine), authors and publishers with cross-links
- /zxdb/labels (+ /[id]): search & label detail with "Authored" and "Published"
  tabs, paginated lists linking to entries
- /zxdb/genres, /zxdb/languages, /zxdb/machinetypes and their /[id] detail pages
  listing paginated entries and deep links
- Navbar: add ZXDB link

Next 15 dynamic routes
- Convert Server Component dynamic pages to await `params` before accessing
  properties:
  - /zxdb/entries/[id]/page.tsx
  - /zxdb/labels/[id]/page.tsx
  - /zxdb/genres/[id]/page.tsx
  - /zxdb/languages/[id]/page.tsx
  - /registers/[hex]/page.tsx (Registers section)
- /api/zxdb/entries/[id]/route.ts: await `ctx.params` before validation

ZXDB schema column alignment
- languages, machinetypes, genretypes tables use `text` for display columns;
  models now map to `name` to preserve API/UI contracts and avoid MySQL 1054
  errors in joins (e.g., entry detail endpoint).

Notes
- Ensure ZXDB helper tables are created (ZXDB/scripts/ZXDB_help_search.sql)
  — required for fast title/name searches and author/publisher lookups.
- Pagination defaults to 20 (max 100). No `select *` used in queries.
- API responses are `cache: no-store` for now; can be tuned later.

Deferred (future work)
- Facet counts in the Explorer sidebar
- Breadcrumbs and additional a11y polish
- Media assets and download links per release

Signed-off-by: Junie@lucy.xalior.com

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 14:41:19 +00:00
dbbad09b1b chore: ZXDB env validation, MySQL setup, API & UI
This sanity commit wires up the initial ZXDB integration and a minimal UI to explore it.

Key changes:
- Add Zod-based env parsing (`src/env.ts`) validating `ZXDB_URL` as a mysql:// URL (t3.gg style).
- Configure Drizzle ORM with mysql2 connection pool (`src/server/db.ts`) driven by `ZXDB_URL`.
- Define minimal ZXDB schema models (`src/server/schema/zxdb.ts`): `entries` and helper `search_by_titles`.
- Implement repository search with pagination using helper table (`src/server/repo/zxdb.ts`).
- Expose Next.js API route `GET /api/zxdb/search` with Zod query validation and Node runtime (`src/app/api/zxdb/search/route.ts`).
- Create new app section “ZXDB Explorer” at `/zxdb` with search UI, results table, and pagination (`src/app/zxdb/*`).
- Add navbar link to ZXDB (`src/components/Navbar.tsx`).
- Update example.env with readonly-role notes and example `ZXDB_URL`.
- Add drizzle-kit config scaffold (`drizzle.config.ts`).
- Update package.json deps: drizzle-orm, mysql2, zod; devDeps: drizzle-kit. Lockfile updated.
- Extend .gitignore to exclude large ZXDB structure dump.

Notes:
- Ensure ZXDB data and helper tables are loaded (see `ZXDB/scripts/ZXDB_help_search.sql`).
- This commit provides structure-only browsing; future work can enrich schema (authors, labels, publishers) and UI filters.

Signed-off-by: Junie@lucy.xalior.com
2025-12-12 14:06:58 +00:00
4222eba8ba Ready to start adding SQL binding 2025-12-12 13:43:30 +00:00
79aabd9b62 Update, before adding massive new feature 2025-12-12 13:28:51 +00:00
99 changed files with 275778 additions and 216 deletions

2
.gitignore vendored
View File

@@ -44,3 +44,5 @@ next-env.d.ts
# PNPM build artifacts
.pnpm
.pnpm-store
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
bin/sync-downloads.mjs

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "ZXDB"]
path = ZXDB
url = https://github.com/zxdb/ZXDB

1
.junie/guidelines.md Symbolic link
View File

@@ -0,0 +1 @@
../AGENTS.md

175
AGENTS.md Normal file
View File

@@ -0,0 +1,175 @@
# AGENT.md
This document provides an overview of the Next Explorer project, its structure, and its implementation details.
## Project Overview
Next Explorer is a web application for exploring the Spectrum Next ecosystem. It is built with Next.js (App Router), React, and TypeScript.
It has two main areas:
- Registers: parsed from `data/nextreg.txt`, browsable with real-time filtering and deep links.
- ZXDB Explorer: a deep, crosslinked browser for ZXDB entries, releases, labels, magazines, genres, languages, and machine types backed by MySQL using Drizzle ORM.
## Project Structure
The project is a Next.js application with the following structure:
```
next-explorer/
├── eslint.config.mjs
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── data/
│ ├── nextreg.txt
│ ├── custom_parsers.txt
│ └── wikilinks.txt
├── node_modules/...
├── public/...
└── src/
├── middleware.js
├── app/
│ ├── layout.tsx
│ ├── page.module.css
│ ├── page.tsx
│ └── registers/
│ ├── page.tsx
│ ├── RegisterBrowser.tsx
│ ├── RegisterDetail.tsx
│ └── [hex]/
│ └── page.tsx
├── components/
│ ├── Navbar.tsx
│ └── ThemeDropdown.tsx
├── scss/
│ ├── _bootswatch.scss
│ ├── _explorer.scss
│ ├── _variables.scss
│ └── nbn.scss
├── services/
│ └── register.service.ts
└── utils/
├── register_parser.ts
└── register_parsers/
├── reg_default.ts
└── reg_f0.ts
```
- **`data/`**: Contains the raw input data for the Spectrum Next explorer.
- `nextreg.txt`: Main register definition file.
- `custom_parsers.txt`, `wikilinks.txt`: Auxiliary configuration/data used by the parser.
- **`src/app/`**: Next.js App Router entrypoint.
- `layout.tsx`: Root layout for all routes.
- `page.tsx`: Application home page.
- `registers/`: Routes and components for the register explorer.
- `page.tsx`: Server Component that loads and lists all registers.
- `RegisterBrowser.tsx`: Client Component implementing search/filter and listing.
- `RegisterDetail.tsx`: Client Component that renders a single registers details, including modes, notes, and source modal.
- `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address.
- `src/app/zxdb/`: ZXDB Explorer routes and client components.
- `page.tsx` + `ZxdbExplorer.tsx`: Search + filters with server-rendered initial content and ISR.
- `entries/[id]/page.tsx` + `EntryDetail.tsx`: Entry details (SSR initial data).
- `releases/page.tsx` + `ReleasesExplorer.tsx`: Releases search + filters.
- `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail.
- `genres/`, `languages/`, `machinetypes/`: Category hubs and detail pages.
- `magazines/`, `issues/`: Magazine and issue browsing.
- `src/app/api/zxdb/`: Zodvalidated API routes (Node runtime) for search and category browsing.
- `src/server/`:
- `env.ts`: Zod env parsing/validation (t3.gg style). Validates `ZXDB_URL` (mysql://).
- `server/db.ts`: Drizzle over `mysql2` singleton pool.
- `server/schema/zxdb.ts`: Minimal Drizzle models (entries, labels, helper tables, lookups).
- `server/repo/zxdb.ts`: Repository queries for search, details, categories, and facets.
- **`src/components/`**: Shared UI components such as `Navbar` and `ThemeDropdown`.
- **`src/services/register.service.ts`**: Service layer responsible for loading and caching parsed register data.
- **`src/utils/register_parser.ts` & `src/utils/register_parsers/`**: Parsing logic for `nextreg.txt`, including mode/bitfield handling and any register-specific parsing extensions.
## Implementation Details
Comment what the code does, not what the agent has done. The documentation's purpose is the state of the application today, not a log of actions taken.
### Data Parsing
- `getRegisters()` in `src/services/register.service.ts`:
- Reads `data/nextreg.txt` from disk.
- Uses `parseNextReg()` from `src/utils/register_parser.ts` to convert the raw text into an array of `Register` objects.
- Returns the in-memory representation of all registers (and can be extended to cache results across calls).
- `parseNextReg()` and related helpers in `register_parser.ts`:
- Parse the custom `nextreg.txt` format into structured data:
- Register addresses (hex/dec).
- Names, notes, and descriptive text.
- Per-mode read/write/common bitfield views.
- Optional source lines and external links (e.g. wiki URLs).
- Delegate special-case parsing to functions in `src/utils/register_parsers/` (e.g. `reg_default.ts`, `reg_f0.ts`) when needed.
### TypeScript Patterns
- No explicity any types.
- Use `const` for constants.
- Use `type` for interfaces.
- No `enum`.
### React / Next.js Patterns
- **Server Components**:
- `src/app/registers/page.tsx` and `src/app/registers/[hex]/page.tsx` are Server Components.
- ZXDB pages under `/zxdb` serverrender initial content for fast first paint, with ISR (`export const revalidate = 3600`) on nonsearch pages.
- Server components call repository functions directly on the server and pass data to client components for presentation.
- **Client Components**:
- `RegisterBrowser.tsx`:
- Marked with `'use client'`.
- Uses React state to manage search input and filtered results.
- `RegisterDetail.tsx`:
- Marked with `'use client'`.
- Renders a single register with tabs for different access modes.
- ZXDB client components (e.g., `ZxdbExplorer.tsx`, `EntryDetail.tsx`, `labels/*`) receive initial data from the server and keep interactions on the client without blocking the first paint.
- **Dynamic Routing**:
- Pages and API routes must await dynamic params in Next.js 15:
- Pages: `export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; }`
- API: `export async function GET(req, ctx: { params: Promise<{ id: string }> }) { const raw = await ctx.params; /* validate with Zod */ }`
- `src/app/registers/[hex]/page.tsx` resolves the `[hex]` segment and calls `notFound()` if absent.
### ZXDB Integration
- Database connection via `mysql2` pool wrapped by Drizzle (`src/server/db.ts`).
- Env validation via Zod (`src/env.ts`) ensures `ZXDB_URL` is a valid `mysql://` URL.
- Supports optional local file mirroring via `ZXDB_LOCAL_FILEPATH` and `WOS_LOCAL_FILEPATH` env vars.
- Minimal Drizzle schema models used for fast search and lookups (`src/server/schema/zxdb.ts`).
- Repository consolidates SQL with typed results (`src/server/repo/zxdb.ts`). Gracefully handles missing tables (e.g. `releases`) by checking `information_schema.tables`.
- API routes under `/api/zxdb/*` validate inputs with Zod and run on Node runtime.
- UI under `/zxdb` is deeply crosslinked and serverrenders initial data for performance. Links use Next `Link` to enable prefetching.
- Helper SQL `ZXDB/scripts/ZXDB_help_search.sql` must be run to create `search_by_*` tables for efficient searches.
- Lookup tables use column `text` for display names; the Drizzle schema maps it as `name`.
### Working Patterns***
- git branching:
- Do not create new branches
- git commits:
- Create or update COMMIT_EDITMSG file if commits pending, await any user
edits, or additional instructions. Once told, commit all the changes
using that commit note, and then delete the COMMIT_EDITMSG file.
Remember to keep the first line as the subject <50char
- git commit messages:
- Use imperative mood (e.g., "Add feature X", "Fix bug Y").
- Include relevant issue numbers if applicable.
- Sign-off commit message as <agent-name>@<hostname>
- validation and review:
- When changes are visual or UX-related, provide concrete links/routes to validate.
- Call out what to inspect visually (e.g., section names, table columns, empty states).
- Use the local `.env` for any environment-dependent behavior.
- Provide fully clickable links when sharing validation URLs.
- submodule hygiene:
- The `ZXDB` submodule is read-only in this repo; do not commit SQL dumps from it.
- Use `bin/setup-zxdb-local.sh` (or `pnpm setup:zxdb-local`) to add local excludes for SQL files.
- deploy workflow:
- `bin/deploy.sh` refuses to run with uncommitted or untracked files at the repo root.
- testing:
- **DO NOT** not restart the dev-server, use the already running one.
- Use tsc -noEmit to check for type errors
- **DO NOT** 'build' the application, Next.js build breaks the dev-server.
### References
- ZXDB setup and API usage: `docs/ZXDB.md`

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -1,14 +1,13 @@
Spectrum Next Explorer
A Next.js application for exploring the Spectrum Next hardware. It includes a Register Explorer with realtime search and deeplinkable queries.
A Next.js application for exploring the Spectrum Next ecosystem. It ships with:
Features
- Register Explorer parsed from `data/nextreg.txt`
- Realtime filtering with querystring deep links (e.g. `/registers?q=vram`)
- Register Explorer: parsed from `data/nextreg.txt`, with realtime search and deep links
- ZXDB Explorer: a deep, crosslinked 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):
@@ -23,14 +22,70 @@ Project scripts (package.json)
- `dev`: `PORT=4000 next dev --turbopack`
- `build`: `next build --turbopack`
- `start`: `next start`
- `deploy`: merge current branch into `deploy` and push to `explorer.specnext.dev`
- `deploy:branch`: same as `deploy`, but accepts a deploy branch argument
- `setup:zxdb-local`: configure local submodule excludes for ZXDB SQL files
- `deploy-test`: push to `test.explorer.specnext.dev`
- `deploy-prod`: push to `explorer.specnext.dev`
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 (hub)
- `/zxdb/entries` — Entries search + filters
- `/zxdb/entries/[id]` — Entry detail
- `/zxdb/releases` — Releases search + filters
- `/zxdb/labels` and `/zxdb/labels/[id]` — Labels search and detail
- `/zxdb/genres` and `/zxdb/genres/[id]` — Genres list and entries
- `/zxdb/languages` and `/zxdb/languages/[id]` — Languages list and entries
- `/zxdb/machinetypes` and `/zxdb/machinetypes/[id]` — Machine types list and entries
- `/zxdb/magazines` and `/zxdb/magazines/[id]` — Magazines list and issues
- `/zxdb/issues/[id]` — Issue detail (contents and references)
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 readonly role/user (recommended):
- Example (see `bin/import_mysql.sh`):
- Create role `zxdb_readonly`
- Grant `SELECT, SHOW VIEW` on database `zxdb`
2) Configure environment
- Copy `.env` from `example.env`.
- Set `ZXDB_URL` to a MySQL URL, e.g. `mysql://zxdb_readonly:password@hostname:3306/zxdb`.
- On startup, `src/env.ts` validates env vars (t3.gg pattern with Zod) and will fail fast if invalid.
3) Run the app
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`.
4) Keep the ZXDB submodule clean (recommended)
- Run `pnpm setup:zxdb-local` once after cloning.
- This keeps `ZXDB/ZXDB_mysql.sql` and `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` available locally without appearing as untracked changes.
API (selected endpoints)
- `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1`
- `GET /api/zxdb/entries/[id]`
- `GET /api/zxdb/releases/search?q=...&year=...&languageId=...&machinetypeId=...&sort=...`
- `GET /api/zxdb/labels/search?q=...`
- `GET /api/zxdb/labels/[id]?page=1&pageSize=20`
- `GET /api/zxdb/genres` and `/api/zxdb/genres/[id]?page=1`
- `GET /api/zxdb/languages` and `/api/zxdb/languages/[id]?page=1`
- `GET /api/zxdb/machinetypes` and `/api/zxdb/machinetypes/[id]?page=1`
- `GET /api/zxdb/{availabletypes,casetypes,currencies,filetypes,roletypes,schemetypes,sourcetypes}`
Implementation notes
- Next.js 15 dynamic params: pages and API routes that consume `params` must await it, e.g. `export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; }`
- ZXDB integration uses Drizzle ORM over `mysql2` with a singleton pool at `src/server/db.ts`; API routes declare `export const runtime = "nodejs"`.
- Entry and detail pages serverrender initial content and use ISR (`revalidate = 3600`) for fast timetocontent; index pages avoid a blocking first client fetch.
- Database Schema: Repository queries include graceful fallback checks (via `information_schema.tables`) to remain functional even if optional tables (like `releases` or `downloads`) are missing from the connected MySQL instance.
Further reading
- ZXDB details and API usage: `docs/ZXDB.md`
- Agent/developer workflow and commit guidelines: `AGENTS.md`
License
- See `LICENSE.txt` for details.

1
ZXDB Submodule

Submodule ZXDB added at dc2edad9ec

23
bin/deploy.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
deploy_branch="${1:-deploy}"
current_branch="$(git rev-parse --abbrev-ref HEAD)"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Working tree is not clean. Commit or stash changes before deploy."
exit 1
fi
if git ls-files --others --exclude-standard | grep -q .; then
echo "Untracked files present. Commit or remove them before deploy."
exit 1
fi
cleanup() {
git checkout "${current_branch}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
git checkout "${deploy_branch}"
git merge --no-edit "${current_branch}"
git push explorer.specnext.dev "${deploy_branch}"

85
bin/import_mysql.sh Normal file
View File

@@ -0,0 +1,85 @@
#!/bin/bash
# Parse connection details from ZXDB_URL in .env
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: .env file not found at $ENV_FILE" >&2
exit 1
fi
ZXDB_URL=$(grep '^ZXDB_URL=' "$ENV_FILE" | cut -d= -f2-)
if [ -z "$ZXDB_URL" ]; then
echo "Error: ZXDB_URL not set in .env" >&2
exit 1
fi
# Unescape backslash-escaped characters (e.g. \$ -> $)
ZXDB_URL=$(echo "$ZXDB_URL" | sed 's/\\\(.\)/\1/g')
# Extract user, password, host, port, database from mysql://user:pass@host:port/db
DB_USER=$(echo "$ZXDB_URL" | sed -n 's|^mysql://\([^:]*\):.*|\1|p')
DB_PASS=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^:]*:\([^@]*\)@.*|\1|p')
DB_HOST=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^@]*@\([^:]*\):.*|\1|p')
DB_PORT=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^@]*@[^:]*:\([0-9]*\)/.*|\1|p')
DB_NAME=$(echo "$ZXDB_URL" | sed -n 's|^mysql://[^/]*/\(.*\)|\1|p')
MYSQL_ARGS="-u${DB_USER} -p${DB_PASS} -h${DB_HOST} -P${DB_PORT}"
echo "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\`;" | mysql $MYSQL_ARGS
mysql $MYSQL_ARGS < ZXDB/ZXDB_mysql.sql
{
echo "SET @OLD_SQL_MODE := @@SESSION.sql_mode;"
echo "SET SESSION sql_mode := REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '');"
cat ZXDB/scripts/ZXDB_help_search.sql
echo "SET SESSION sql_mode := @OLD_SQL_MODE;"
# echo "CREATE ROLE IF NOT EXISTS 'zxdb_readonly';"
# echo "GRANT SELECT, SHOW VIEW ON \`zxdb\`.* TO 'zxdb_readonly';"
} | mysql --force $MYSQL_ARGS "$DB_NAME"
# ---- Reimport software_hashes from JSON snapshot if available ----
HASHES_SNAPSHOT="$SCRIPT_DIR/../data/zxdb/software_hashes.json"
if [ -f "$HASHES_SNAPSHOT" ]; then
echo "Reimporting software_hashes from $HASHES_SNAPSHOT ..."
node -e "
const fs = require('fs');
const mysql = require('mysql2/promise');
(async () => {
const snap = JSON.parse(fs.readFileSync('$HASHES_SNAPSHOT', 'utf8'));
if (!snap.rows || snap.rows.length === 0) {
console.log(' No rows in snapshot, skipping.');
return;
}
const pool = mysql.createPool({ uri: '$ZXDB_URL', connectionLimit: 1 });
await pool.query(\`
CREATE TABLE IF NOT EXISTS software_hashes (
download_id INT NOT NULL PRIMARY KEY,
md5 VARCHAR(32) NOT NULL,
crc32 VARCHAR(8) NOT NULL,
size_bytes BIGINT NOT NULL,
inner_path VARCHAR(500) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_sh_md5 (md5),
INDEX idx_sh_crc32 (crc32)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
\`);
await pool.query('TRUNCATE TABLE software_hashes');
// Batch insert in chunks of 500
const chunk = 500;
for (let i = 0; i < snap.rows.length; i += chunk) {
const batch = snap.rows.slice(i, i + chunk);
const values = batch.map(r => [r.download_id, r.md5, r.crc32, r.size_bytes, r.inner_path, r.updated_at]);
await pool.query(
'INSERT INTO software_hashes (download_id, md5, crc32, size_bytes, inner_path, updated_at) VALUES ?',
[values]
);
}
console.log(' Imported ' + snap.rows.length + ' rows into software_hashes.');
await pool.end();
})().catch(e => { console.error(' Error reimporting software_hashes:', e.message); process.exit(0); });
"
else
echo "No software_hashes snapshot found at $HASHES_SNAPSHOT — skipping reimport."
fi
mysqldump --no-data -uroot -p -h${DB_HOST} -P${DB_PORT} "$DB_NAME" > ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql

18
bin/setup-zxdb-local.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
git_dir="$(git -C ZXDB rev-parse --git-dir)"
exclude_file="${git_dir}/info/exclude"
mkdir -p "$(dirname "${exclude_file}")"
touch "${exclude_file}"
add_exclude() {
local pattern="$1"
if ! grep -Fxq "${pattern}" "${exclude_file}"; then
printf "%s\n" "${pattern}" >> "${exclude_file}"
fi
}
add_exclude "ZXDB_mysql.sql"
add_exclude "ZXDB_mysql_STRUCTURE_ONLY.sql"

498
bin/update-software-hashes.mjs Executable file
View File

@@ -0,0 +1,498 @@
#!/usr/bin/env node
// Compute MD5, CRC32 and size for the inner tape file inside each download zip.
// Populates the `software_hashes` table and exports a JSON snapshot to
// data/zxdb/software_hashes.json for reimport after DB wipes.
//
// Usage:
// node bin/update-software-hashes.mjs [flags]
//
// Flags:
// --rebuild-all Ignore state and reprocess every download
// --rebuild-missing Only process downloads not yet in software_hashes
// --start-from-id=N Start processing from download id N
// --export-only Skip processing, just export current table to JSON
// --quiet Reduce log output
// --verbose Force verbose output (default)
import dotenv from "dotenv";
import dotenvExpand from "dotenv-expand";
dotenvExpand.expand(dotenv.config());
import { z } from "zod";
import mysql from "mysql2/promise";
import fs from "fs/promises";
import path from "path";
import { createReadStream } from "fs";
import { createHash } from "crypto";
import { pipeline } from "stream/promises";
import { Transform } from "stream";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, "..");
// ---- CLI flags ----
const ARGV = new Set(process.argv.slice(2));
const QUIET = ARGV.has("--quiet");
const VERBOSE = ARGV.has("--verbose") || !QUIET;
const REBUILD_ALL = ARGV.has("--rebuild-all");
const REBUILD_MISSING = ARGV.has("--rebuild-missing");
const EXPORT_ONLY = ARGV.has("--export-only");
// Parse --start-from-id=N
let CLI_START_FROM = 0;
for (const arg of process.argv.slice(2)) {
const m = arg.match(/^--start-from-id=(\d+)$/);
if (m) CLI_START_FROM = parseInt(m[1], 10);
}
function logInfo(msg) { if (VERBOSE) console.log(msg); }
function logWarn(msg) { console.warn(msg); }
function logError(msg) { console.error(msg); }
// ---- Environment ----
const envSchema = z.object({
ZXDB_URL: z.string().url().refine((s) => s.startsWith("mysql://"), {
message: "ZXDB_URL must be a valid mysql:// URL",
}),
CDN_CACHE: z.string().min(1, "CDN_CACHE must be set to the local CDN mirror root"),
});
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
logError("Invalid environment variables:\n" + JSON.stringify(parsedEnv.error.format(), null, 2));
process.exit(1);
}
const { ZXDB_URL, CDN_CACHE } = parsedEnv.data;
const SNAPSHOT_PATH = path.join(PROJECT_ROOT, "data", "zxdb", "software_hashes.json");
const STATE_FILE = path.join(CDN_CACHE, ".update-software-hashes.state.json");
// Filetype IDs for tape images
const TAPE_FILETYPE_IDS = [8, 22];
// Tape file extensions in priority order (most common first)
const TAPE_EXTENSIONS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
// ---- DB ----
const pool = mysql.createPool({
uri: ZXDB_URL,
connectionLimit: 10,
maxPreparedStatements: 256,
});
// ---- Path mapping (mirrors sync-downloads.mjs) ----
function toLocalPath(fileLink) {
if (fileLink.startsWith("/zxdb/sinclair/")) {
return path.join(CDN_CACHE, "SC", fileLink.slice("/zxdb/sinclair".length));
}
if (fileLink.startsWith("/pub/sinclair/")) {
return path.join(CDN_CACHE, "WoS", fileLink.slice("/pub/sinclair".length));
}
return null;
}
// ---- State management ----
async function loadState() {
try {
const raw = await fs.readFile(STATE_FILE, "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
async function saveStateAtomic(state) {
const tmp = STATE_FILE + ".tmp";
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
await fs.rename(tmp, STATE_FILE);
}
// ---- Zip extraction ----
// Use Node.js built-in (node:zlib for deflate) + manual zip parsing
// to avoid external dependencies. Zip files in ZXDB are simple (no encryption, single file).
async function extractZipContents(zipPath, contentsDir) {
const { execFile } = await import("child_process");
const { promisify } = await import("util");
const execFileAsync = promisify(execFile);
await fs.mkdir(contentsDir, { recursive: true });
try {
// Use system unzip, quoting the path to handle brackets in filenames
await execFileAsync("unzip", ["-o", "-d", contentsDir, zipPath], {
maxBuffer: 50 * 1024 * 1024,
});
} catch (err) {
// unzip returns exit code 1 for warnings (e.g. "appears to use backslashes")
// which is non-fatal — only fail on actual extraction errors
if (err.code !== 1) {
throw new Error(`unzip failed for ${zipPath}: ${err.message}`);
}
}
}
// ---- Find tape file inside _CONTENTS ----
async function findTapeFile(contentsDir) {
let entries;
try {
entries = await fs.readdir(contentsDir, { recursive: true, withFileTypes: true });
} catch {
return null;
}
// Collect all tape files grouped by extension priority
const candidates = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
const priority = TAPE_EXTENSIONS.indexOf(ext);
if (priority === -1) continue;
const fullPath = path.join(entry.parentPath ?? entry.path, entry.name);
candidates.push({ path: fullPath, ext, priority, name: entry.name });
}
if (candidates.length === 0) return null;
// Sort by priority (lowest index = highest priority), then alphabetically
candidates.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
// Return the best candidate
return candidates[0];
}
// ---- Hash computation ----
async function computeHashes(filePath) {
const md5 = createHash("md5");
let crc = 0xFFFFFFFF;
let size = 0;
// CRC32 lookup table
const crcTable = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
crcTable[i] = c;
}
const transform = new Transform({
transform(chunk, encoding, callback) {
md5.update(chunk);
size += chunk.length;
for (let i = 0; i < chunk.length; i++) {
crc = crcTable[(crc ^ chunk[i]) & 0xFF] ^ (crc >>> 8);
}
callback(null, chunk);
},
});
const stream = createReadStream(filePath);
// Pipe through transform (which computes hashes) and discard output
await pipeline(stream, transform, async function* (source) {
for await (const _ of source) { /* drain */ }
});
const crc32Final = ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0");
return {
md5: md5.digest("hex"),
crc32: crc32Final,
sizeBytes: size,
};
}
// ---- Ensure software_hashes table exists ----
async function ensureTable() {
await pool.query(`
CREATE TABLE IF NOT EXISTS software_hashes (
download_id INT NOT NULL PRIMARY KEY,
md5 VARCHAR(32) NOT NULL,
crc32 VARCHAR(8) NOT NULL,
size_bytes BIGINT NOT NULL,
inner_path VARCHAR(500) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_sh_md5 (md5),
INDEX idx_sh_crc32 (crc32)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
}
// ---- JSON export ----
async function exportSnapshot() {
const [rows] = await pool.query(
"SELECT download_id, md5, crc32, size_bytes, inner_path, updated_at FROM software_hashes ORDER BY download_id"
);
const snapshot = {
exportedAt: new Date().toISOString(),
count: rows.length,
rows: rows.map((r) => ({
download_id: r.download_id,
md5: r.md5,
crc32: r.crc32,
size_bytes: Number(r.size_bytes),
inner_path: r.inner_path,
updated_at: r.updated_at instanceof Date ? r.updated_at.toISOString() : r.updated_at,
})),
};
// Ensure directory exists
await fs.mkdir(path.dirname(SNAPSHOT_PATH), { recursive: true });
// Atomic write
const tmp = SNAPSHOT_PATH + ".tmp";
await fs.writeFile(tmp, JSON.stringify(snapshot, null, 2), "utf8");
await fs.rename(tmp, SNAPSHOT_PATH);
logInfo(`Exported ${rows.length} rows to ${SNAPSHOT_PATH}`);
return rows.length;
}
// ---- Main processing loop ----
let currentState = null;
async function main() {
await ensureTable();
if (EXPORT_ONLY) {
const count = await exportSnapshot();
logInfo(`Export complete: ${count} rows.`);
await pool.end();
return;
}
// Determine start point
const prior = await loadState();
let resumeFrom = CLI_START_FROM;
if (!REBUILD_ALL && !CLI_START_FROM && prior?.lastProcessedId) {
resumeFrom = prior.lastProcessedId + 1;
}
const startedAt = new Date().toISOString();
currentState = {
version: 1,
startedAt,
updatedAt: startedAt,
startFromId: resumeFrom,
lastProcessedId: prior?.lastProcessedId ?? -1,
processed: 0,
hashed: 0,
skipped: 0,
errors: 0,
error: undefined,
};
// Query tape-image downloads
const placeholders = TAPE_FILETYPE_IDS.map(() => "?").join(", ");
let rows;
if (REBUILD_MISSING) {
// Only fetch downloads that don't already have a hash
[rows] = await pool.query(
`SELECT d.id, d.file_link, d.file_size FROM downloads d
LEFT JOIN software_hashes sh ON sh.download_id = d.id
WHERE d.filetype_id IN (${placeholders}) AND sh.download_id IS NULL
ORDER BY d.id ASC`,
TAPE_FILETYPE_IDS
);
} else {
[rows] = await pool.query(
`SELECT id, file_link, file_size FROM downloads
WHERE filetype_id IN (${placeholders}) AND id >= ?
ORDER BY id ASC`,
[...TAPE_FILETYPE_IDS, resumeFrom]
);
}
// Also get total count for progress display
const [totalRows] = await pool.query(
`SELECT COUNT(*) as cnt FROM downloads WHERE filetype_id IN (${placeholders})`,
TAPE_FILETYPE_IDS
);
const total = totalRows[0].cnt;
const mode = REBUILD_MISSING ? "missing only" : REBUILD_ALL ? "rebuild all" : `from id >= ${resumeFrom}`;
logInfo(`Processing ${rows.length} tape-image downloads (total in DB: ${total}, mode: ${mode})`);
let processed = 0;
let hashed = 0;
let skipped = 0;
let errors = 0;
for (const row of rows) {
const { id, file_link: fileLink } = row;
try {
const localZip = toLocalPath(fileLink);
if (!localZip) {
// /denied/ and other non-hosted prefixes — skip silently
skipped++;
processed++;
currentState.lastProcessedId = id;
if (processed % 500 === 0) {
await checkpoint();
}
continue;
}
// Check if zip exists locally
try {
await fs.access(localZip);
} catch {
// Zip not synced yet — skip silently
skipped++;
processed++;
currentState.lastProcessedId = id;
if (processed % 500 === 0) {
await checkpoint();
}
continue;
}
// Check/create _CONTENTS
const contentsDir = localZip + "_CONTENTS";
let contentsExisted = false;
try {
await fs.access(contentsDir);
contentsExisted = true;
} catch {
// Need to extract
}
if (!contentsExisted) {
try {
await extractZipContents(localZip, contentsDir);
} catch (err) {
logWarn(` [${id}] Extract failed: ${err.message}`);
errors++;
processed++;
currentState.lastProcessedId = id;
continue;
}
}
// Find tape file
const tapeFile = await findTapeFile(contentsDir);
if (!tapeFile) {
// No tape file found inside zip — unusual but not fatal
if (VERBOSE) logWarn(` [${id}] No tape file in ${contentsDir}`);
skipped++;
processed++;
currentState.lastProcessedId = id;
continue;
}
// Compute hashes
const hashes = await computeHashes(tapeFile.path);
// Relative path inside _CONTENTS for the inner_path column
const innerPath = path.relative(contentsDir, tapeFile.path);
// Upsert
await pool.query(
`INSERT INTO software_hashes (download_id, md5, crc32, size_bytes, inner_path, updated_at)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
md5 = VALUES(md5),
crc32 = VALUES(crc32),
size_bytes = VALUES(size_bytes),
inner_path = VALUES(inner_path),
updated_at = NOW()`,
[id, hashes.md5, hashes.crc32, hashes.sizeBytes, innerPath]
);
hashed++;
processed++;
currentState.lastProcessedId = id;
currentState.hashed = hashed;
currentState.processed = processed;
currentState.skipped = skipped;
currentState.errors = errors;
currentState.updatedAt = new Date().toISOString();
if (processed % 100 === 0) {
await checkpoint();
logInfo(`... processed=${processed}/${rows.length}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
}
} catch (err) {
logError(` [${id}] Unexpected error: ${err.message}`);
errors++;
processed++;
currentState.lastProcessedId = id;
currentState.errors = errors;
}
}
// Final state save
currentState.processed = processed;
currentState.hashed = hashed;
currentState.skipped = skipped;
currentState.errors = errors;
currentState.updatedAt = new Date().toISOString();
await saveStateAtomic(currentState);
logInfo(`\nProcessing complete: processed=${processed}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
// Export snapshot
logInfo("\nExporting JSON snapshot...");
await exportSnapshot();
await pool.end();
logInfo("Done.");
async function checkpoint() {
currentState.processed = processed;
currentState.hashed = hashed;
currentState.skipped = skipped;
currentState.errors = errors;
currentState.updatedAt = new Date().toISOString();
try {
await saveStateAtomic(currentState);
} catch (e) {
logError(`Failed to write state: ${e?.message || e}`);
}
}
}
// ---- Graceful shutdown ----
process.on("SIGINT", async () => {
logWarn("\nInterrupted (SIGINT). Writing state...");
try {
if (currentState) {
currentState.updatedAt = new Date().toISOString();
await saveStateAtomic(currentState);
logWarn(`State saved at: ${STATE_FILE}`);
}
} catch (e) {
logError(`Failed to write state on SIGINT: ${e?.message || e}`);
}
try { await pool.end(); } catch {}
process.exit(130);
});
// Run
main().catch(async (err) => {
logError(`Fatal error: ${err.message}\n${err.stack || "<no stack>"}`);
try {
if (currentState) {
currentState.updatedAt = new Date().toISOString();
currentState.error = { message: err.message, stack: err.stack };
await saveStateAtomic(currentState);
}
} catch (e) {
logError(`Failed to write state on fatal: ${e?.message || e}`);
}
try { await pool.end(); } catch {}
process.exit(1);
});

0
data/zxdb/.gitkeep Normal file
View File

263686
data/zxdb/software_hashes.json Normal file

File diff suppressed because it is too large Load Diff

147
docs/ZXDB.md Normal file
View File

@@ -0,0 +1,147 @@
# ZXDB Guide
This document explains how the ZXDB Explorer works in this project, how to set up the database connection, and how to use the builtin API and UI for software discovery.
## What is ZXDB?
ZXDB (https://github.com/zxdb/ZXDB) is a communitymaintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in readonly mode and expose a fast, crosslinked 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 readonly MySQL user for the app (recommended).
- The `ZXDB` submodule is checked in for schemas/scripts; use `pnpm setup:zxdb-local` after cloning to keep local SQL dumps untracked.
## Database setup
1. Import ZXDB data into MySQL.
- Extract and import https://github.com/zxdb/ZXDB/blob/master/ZXDB_mysql.sql.zip
2. Create helper search tables (required).
- Run `https://github.com/zxdb/ZXDB/blob/master/scripts/ZXDB_help_search.sql` on your ZXDB database.
- This creates `search_by_titles`, `search_by_names`, `search_by_authors`, `search_by_publishers`, `search_by_aliases`, `search_by_origins`,
`search_by_magrefs`, `search_by_magazines`, and `search_by_issues` tables used for search scopes and magazine references.
3. Create a readonly role/user (recommended).
- Create user `zxdb_readonly`.
- Grant `SELECT, SHOW VIEW` on your `zxdb` database to the user.
## Environment configuration
Set the connection string in `.env`:
```
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
```
Notes:
- The URL must start with `mysql://`. Env is validated at boot by `src/env.ts` (Zod), failing fast if misconfigured.
- The app uses a singleton `mysql2` pool (`src/server/db.ts`) and Drizzle ORM for typed queries.
### Local File Mirrors
The explorer can optionally show "Local Mirror" links for downloads if you have local copies of the ZXDB and World of Spectrum (WoS) file archives.
#### Configuration
To enable local mirrors, set the following variables in your `.env`:
```bash
# Absolute paths to your local mirrors
ZXDB_LOCAL_FILEPATH=/path/to/your/zxdb/mirror
WOS_LOCAL_FILEPATH=/path/to/your/wos/mirror
# Optional: Remote path prefixes to strip from database links before prepending local paths
ZXDB_FILE_PREFIX=/zxdb/sinclair/
WOS_FILE_PREFIX=/pub/sinclair/
```
#### How it works
1. The app identifies if a download link matches `ZXDB_FILE_PREFIX` or `WOS_FILE_PREFIX`.
2. It strips the prefix from the database link.
3. It joins the remaining relative path to the corresponding `*_LOCAL_FILEPATH`.
4. It checks if the file exists on the local disk.
5. If the file exists and the environment variable is set, a "Local Mirror" link is displayed in the UI, pointing to a proxy download API (`/api/zxdb/download`).
Note: Obtaining these mirrors is left as an exercise to the host. The paths do not need to share a common parent directory. Both mirrors are optional and independent; you can configure one, both, or neither.
## Running
```
pnpm install
pnpm dev
# open http://localhost:4000 and navigate to /zxdb
```
## Explorer UI overview
- `/zxdb` — Search entries by title and filter by genre, language, and machine type; sort and paginate results.
- `/zxdb/entries` — Entries search with scope toggles (titles/aliases/origins) and facets.
- `/zxdb/entries/[id]` — Entry details with related releases, downloads, origins, relations, and media.
- `/zxdb/releases` — Releases search + filters.
- `/zxdb/releases/[entryId]/[releaseSeq]` — Release detail: magazine references, downloads, scraps, and issue files.
- `/zxdb/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies), permissions, licenses, and authored/published entries.
- `/zxdb/genres`, `/zxdb/languages`, `/zxdb/machinetypes` — Category hubs with linked detail pages listing entries.
- `/zxdb/magazines` and `/zxdb/magazines/[id]` — Magazine list and issue navigation.
- `/zxdb/issues/[id]` — Issue detail with contents and references.
Crosslinking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched.
Performance: Detail and index pages are serverrendered with initial data and use ISR (`revalidate = 3600`) to reduce timetofirstcontent. 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 (freetext search; normalized via helper tables)
- `page`, `pageSize` — pagination (default pageSize=20, max=100)
- `genreId`, `languageId`, `machinetypeId` — optional filters
- `sort``title` or `id_desc`
- `scope``title`, `title_aliases`, or `title_aliases_origins`
- `facets` — boolean; if truthy, includes facet counts for genres/languages/machines
- Entry detail
- `GET /api/zxdb/entries/[id]`
- Returns: entry core fields, joined genre/language/machinetype names, authors and publishers.
- Labels
- `GET /api/zxdb/labels/search?q=...`
- `GET /api/zxdb/labels/[id]?page=1&pageSize=20` — includes `authored` and `published` lists.
- Categories
- `GET /api/zxdb/genres` and `/api/zxdb/genres/[id]?page=1`
- `GET /api/zxdb/languages` and `/api/zxdb/languages/[id]?page=1`
- `GET /api/zxdb/machinetypes` and `/api/zxdb/machinetypes/[id]?page=1`
Runtime: API routes declare `export const runtime = "nodejs"` to support `mysql2`.
## Implementation notes
- Drizzle models map ZXDB lookup table column `text` to property `name` for ergonomics (e.g., `languages.text``name`).
- Next.js 15 dynamic params must be awaited in App Router pages and API routes. Example:
```ts
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// ...
}
```
- Repository queries parallelize independent calls with `Promise.all` for lower latency.
## Troubleshooting
- 400 from dynamic API routes: ensure you await `ctx.params` before Zod validation.
- Missing facets or scope toggles: ensure helper tables from `ZXDB_help_search.sql` exist.
- Unknown column errors for lookup names: ZXDB tables use column `text` for names; Drizzle schema must select `text` as `name`.
- Slow entry page: confirm serverrendering is active and ISR is set; client components should not fetch on the first paint when initial props are provided.
- MySQL auth or network errors: verify `ZXDB_URL` and that your user has read permissions.
## Roadmap
- Issue-centric media grouping and richer magazine metadata.
- Additional cross-links for tags, relations, and permissions as UI expands.
- A11y polish and higher-level navigation enhancements.

View File

@@ -15,6 +15,12 @@ Run in development
- Command: pnpm dev
- Then open: http://localhost:4000
ZXDB submodule local setup
- The ZXDB repo is a submodule used as a read-only reference for schemas/scripts.
- Some local SQL files are expected to exist but should stay untracked.
- Run: pnpm setup:zxdb-local
- This adds local excludes inside the submodule so `git status` stays clean.
Build and start (production)
- Build: pnpm build
- Start: pnpm start
@@ -24,7 +30,9 @@ Lint
- pnpm lint
Deployment shortcuts
- Two scripts are available in package.json:
- Use pnpm deploy (or pnpm deploy:branch) to merge the current branch into `deploy` and push to explorer.specnext.dev.
- The deploy script refuses to run if there are uncommitted or untracked files.
- One-step push helpers (if you prefer manual branch selection):
- pnpm deploy-test: push the current branch to test.explorer.specnext.dev
- pnpm deploy-prod: push the current branch to explorer.specnext.dev
Ensure the corresponding Git remotes are configured locally before using these.
- Ensure the corresponding Git remotes are configured locally before using these.

View File

@@ -5,5 +5,6 @@ Welcome to the Spectrum Next Explorer docs. This site provides an overview of th
- Getting Started: ./getting-started.md
- Architecture: ./architecture.md
- Register Explorer: ./registers.md
- ZXDB Explorer: ./ZXDB.md
If youre browsing on GitHub, the main README also links to these documents.

View File

@@ -0,0 +1,75 @@
# WIP: Software Hashes
**Branch:** `feature/software-hashes`
**Started:** 2026-02-17
**Status:** Complete
## Plan
Implements [docs/plans/software-hashes.md](software-hashes.md) — a derived `software_hashes` table storing MD5, CRC32 and size for tape-image contents extracted from download zips.
### Tasks
- [x] Create `data/zxdb/` directory (for JSON snapshot)
- [x] Add `software_hashes` Drizzle schema model
- [x] Create `bin/update-software-hashes.mjs` — main pipeline script
- [x] DB query for tape-image downloads (filetype_id IN 8, 22)
- [x] Resolve local zip path via CDN mapping (uses CDN_CACHE env var)
- [x] Extract `_CONTENTS` (skip if exists)
- [x] Find tape file (.tap/.tzx/.pzx/.csw) with priority order
- [x] Compute MD5, CRC32, size_bytes
- [x] Upsert into software_hashes
- [x] State file for resume support
- [x] JSON export after bulk update (atomic write)
- [x] Update `bin/import_mysql.sh` to reimport snapshot on DB wipe
- [x] Add pnpm script entries
## Progress Log
### 2026-02-17T16:00Z
- Started work. Branch created from `main` at `b361201`.
- Explored codebase: understood DB schema, CDN mapping, import pipeline.
- Key findings:
- filetype_id 8 = "Tape image" (33,427 rows), 22 = "BUGFIX tape image" (98 rows)
- CDN_CACHE = /Volumes/McFiver/CDN, paths: SC/ (zxdb) and WoS/ (pub)
- `_CONTENTS` dirs exist in WoS but not yet in SC
- data/zxdb/ directory needs creation
- import_mysql.sh needs software_hashes reimport step
### 2026-02-17T16:04Z
- Implemented Drizzle schema model for `software_hashes`.
- Created `bin/update-software-hashes.mjs` pipeline script.
- Updated `bin/import_mysql.sh` with JSON snapshot reimport.
- Added `update:hashes` and `export:hashes` pnpm scripts.
### 2026-02-17T16:09Z
- First full run completed successfully:
- 33,525 total tape-image downloads in DB
- 32,305 rows hashed and inserted into software_hashes
- ~1,220 skipped (missing local zips, `/denied/` prefix, `.p` ZX81 files with no tape content)
- JSON snapshot exported: 7.2MB, 32,305 rows at `data/zxdb/software_hashes.json`
- All plan steps verified working.
## Decisions & Notes
- Target filetype IDs: 8 and 22 (tape image + bugfix tape image).
- Tape file priority: .tap > .tzx > .pzx > .csw (most common first).
- CDN_CACHE comes from env var (not hard-coded, unlike sync-downloads.mjs).
- JSON snapshot at data/zxdb/software_hashes.json (7.2MB, committed to repo).
- Node.js built-in `crypto` for MD5; custom CRC32 lookup table (no external deps).
- `inner_path` column added (not in original plan) to record which file inside the zip was hashed.
- `/denied/` and `/nvg/` prefix downloads (~443) are logged and skipped (no local mirror).
- `.p` files (ZX81 programs) categorized as tape images but contain no .tap/.tzx/.pzx/.csw — logged as "no tape file".
- Uses system `unzip` for extraction (handles bracket-heavy filenames via `execFile` not shell).
## Blockers
None.
## Commits
b361201 - Ready to start adding hashes
944a2dc - wip: start feature/software-hashes — init progress tracker
f5ae89e - feat: add software_hashes table schema and reimport pipeline
edc937a - feat: add update-software-hashes.mjs pipeline script
9bfebc1 - feat: add initial software_hashes JSON snapshot (32,305 rows)

View File

@@ -0,0 +1,44 @@
# WIP: Tape Identifier Dropzone
**Branch:** `feature/software-hashes`
**Started:** 2026-02-17
**Status:** Complete
## Plan
Implements the tape identifier feature from [docs/plans/tape-identifier.md](tape-identifier.md).
Drop a tape file on the /zxdb page → client computes MD5 + size → server action looks up `software_hashes` → returns identified ZXDB entry.
### Tasks
- [x] Add `lookupByMd5()` to `src/server/repo/zxdb.ts`
- [x] Create `src/utils/md5.ts` — pure-JS MD5 for browser
- [x] Create `src/app/zxdb/actions.ts` — server action `identifyTape`
- [x] Create `src/app/zxdb/TapeIdentifier.tsx` — client component with dropzone
- [x] Insert `<TapeIdentifier />` into `src/app/zxdb/page.tsx`
- [ ] Verify on http://localhost:4000/zxdb
## Progress Log
### 2026-02-17T00:00
- Started work. Continuing on `feature/software-hashes` at `e27a16e`.
### 2026-02-17T00:01
- All implementation complete. Type check passes. Ready for visual verification.
## Decisions & Notes
- Uses RSC server actions (not API routes) to discourage bulk scripting.
- MD5 computed client-side; file never leaves the browser.
- No new npm dependencies — pure-JS MD5 implementation (~130 lines).
- TapeIdentifier placed between hero and "Start exploring" grid in a row layout with explanatory text alongside.
## Blockers
None.
## Commits
fc513c5 - wip: start tape identifier — init progress tracker
8624050 - feat: add tape identifier dropzone on /zxdb

View File

@@ -0,0 +1,155 @@
# Software Hashes Plan
Plan for adding a derived `software_hashes` table, its update pipeline, and JSON snapshot lifecycle to survive DB wipes.
---
## 1) Goals and Scope (Plan Step 1)
- Create and maintain `software_hashes` for (at this stage) tape-image downloads.
- Preserve existing `_CONTENTS` folders; only create missing ones.
- Export `software_hashes` to JSON after each bulk update.
- Reimport `software_hashes` JSON during DB wipe in `bin/import_mysql.sh` (or a helper script it invokes).
- Ensure all scripts are idempotent and resume-safe.
---
## 2) Confirm Pipeline Touchpoints (Plan Step 2)
- Verify `bin/import_mysql.sh` is the authoritative DB wipe/import entry point.
- Confirm `bin/sync-downloads.mjs` remains responsible only for CDN cache sync.
- Confirm `src/server/schema/zxdb.ts` uses `downloads.id` as the natural FK target.
---
## 3) Define Data Model: `software_hashes` (Plan Step 3)
### Table naming and FK alignment
- Table: `software_hashes`.
- FK: `download_id``downloads.id`.
- Column names follow existing DB `snake_case` conventions.
### Planned columns
- `download_id` (PK or unique index; FK to `downloads.id`)
- `md5`
- `crc32`
- `size_bytes`
- `updated_at`
### Planned indexes / constraints
- Unique index on `download_id`.
- Index on `md5` for reverse lookup.
- Index on `crc32` for reverse lookup.
---
## 4) Define JSON Snapshot Format (Plan Step 4)
### Location
- Default: `data/zxdb/software_hashes.json` (or another agreed path).
### Structure
```json
{
"exportedAt": "2026-02-17T15:18:00.000Z",
"rows": [
{
"download_id": 123,
"md5": "...",
"crc32": "...",
"size_bytes": 12345,
"updated_at": "2026-02-17T15:18:00.000Z"
}
]
}
```
### Planned import policy
- If snapshot exists: truncate `software_hashes` and bulk insert.
- If snapshot missing: log and continue without error.
---
## 5) Implement Tape Image Update Workflow (Plan Step 5)
### Planned script
- `bin/update-software-hashes.mjs` (name can be adjusted).
### Planned input dataset
- Query `downloads` for tape-image rows (filter by `filetype_id` or joined `filetypes` table).
### Planned per-item process
1. Resolve local zip path using the same CDN mapping used by `sync-downloads`.
2. Compute `_CONTENTS` folder name: `<zip filename>_CONTENTS` (exact match).
3. If `_CONTENTS` exists, keep it untouched.
4. If missing, extract zip into `_CONTENTS` using a library that avoids shell expansion issues with brackets.
5. Locate tape file inside (`.tap`, `.tzx`, `.pzx`, `.csw`):
- Apply a deterministic priority order.
- If multiple candidates remain, log and skip (or record ambiguity).
6. Compute `md5`, `crc32`, and `size_bytes` for the selected file.
7. Upsert into `software_hashes` keyed by `download_id`.
### Planned error handling
- Log missing zips or missing tape files.
- Continue after recoverable errors; fail only on critical DB errors.
---
## 6) Implement JSON Export Lifecycle (Plan Step 6)
- After each bulk update, export `software_hashes` to JSON.
- Write atomically (temp file + rename).
- Include `exportedAt` timestamp in snapshot.
---
## 7) Reimport During Wipe (`bin/import_mysql.sh`) (Plan Step 7)
### Planned placement
- Immediately after database creation and ZXDB SQL import completes.
### Planned behavior
- Attempt to read JSON snapshot.
- If present, truncate and reinsert `software_hashes`.
- Log imported row count.
---
## 8) Add Idempotency and Resume Support (Plan Step 8)
- State file similar to `.sync-downloads.state.json` to track last `download_id` processed.
- CLI flags:
- `--resume` (default)
- `--start-from-id`
- `--rebuild-all`
- Reprocess when zip file size or mtime changes.
---
## 9) Validation Checklist (Plan Step 9)
- `_CONTENTS` folders are never deleted.
- Hashes match expected MD5/CRC32 for known samples.
- JSON snapshot is created and reimported correctly.
- Reverse lookup by `md5`/`crc32`/`size_bytes` identifies misnamed files.
- Script can resume safely after interruption.
---
## 10) Open Questions / Confirmations (Plan Step 10)
- Final `software_hashes` column list and types.
- Exact JSON snapshot path.
- Filetype IDs that map to “Tape Image” in `downloads`.

View File

@@ -0,0 +1,67 @@
# Plan: Tape Identifier Dropzone on /zxdb
## Context
We have 32,960 rows in `software_hashes` with MD5, CRC32, size, and inner_path for tape-image contents. This feature exposes that data to users: drop a tape file, get it identified against the ZXDB database.
Uses RSC (server actions) rather than an API endpoint to make bulk scripted identification harder.
## Architecture
**Client-side:** Compute MD5 + file size in the browser, then call a server action with just those two values (file never leaves the client).
**Server-side:** A Next.js Server Action looks up `software_hashes` by MD5 (and optionally size_bytes for disambiguation), joins to `downloads` and `entries` to return the entry title, download details, and a link.
**Client-side MD5:** Web Crypto doesn't support MD5. Include a small pure-JS MD5 utility (~80 lines, well-known algorithm). No new npm dependencies.
## Files to Create/Modify
### 1. `src/utils/md5.ts` — Pure-JS MD5 for browser use
- Exports `async function computeMd5(file: File): Promise<string>`
- Reads file as ArrayBuffer, computes MD5, returns hex string
- Standard MD5 algorithm implementation, typed for TypeScript
### 2. `src/app/zxdb/actions.ts` — Server Action
- `'use server'` directive
- `identifyTape(md5: string, sizeBytes: number)`
- Queries `software_hashes` JOIN `downloads` JOIN `entries` by MD5
- If multiple matches and size_bytes narrows it, filter further
- Returns array of `{ downloadId, entryId, entryTitle, innerPath, md5, crc32, sizeBytes }`
### 3. `src/app/zxdb/TapeIdentifier.tsx` — Client Component
- `'use client'`
- States: `idle``hashing``identifying``results` / `not-found`
- Dropzone UI:
- Dashed border card, large tape icon, "Drop a tape file to identify it"
- Lists supported formats: `.tap .tzx .pzx .csw .p .o`
- Also has a hidden `<input type="file">` with a "or choose file" link
- Drag-over highlight state
- On file drop/select:
- Validate extension against supported list
- Show spinner + "Computing hash..."
- Compute MD5 + size client-side
- Call server action `identifyTape(md5, size)`
- Show spinner + "Searching ZXDB..."
- Results view (replaces dropzone):
- Match found: entry title as link to `/zxdb/entries/{id}`, inner filename, MD5, file size
- Multiple matches: list all
- No match: "No matching tape found in ZXDB"
- "Identify another tape" button to reset
### 4. `src/app/zxdb/page.tsx` — Add TapeIdentifier section
- Insert `<TapeIdentifier />` as a new section between the hero and "Start exploring" grid
- Wrap in a card with distinct styling to make it visually prominent
### 5. `src/server/repo/zxdb.ts` — Add lookup function
- `lookupByMd5(md5: string)` — joins `software_hashes``downloads``entries`
- Returns download_id, entry_id, entry title, inner_path, hash details
## Verification
- Visit http://localhost:4000/zxdb
- Dropzone should be visible and prominent between hero and navigation grid
- Drop a known .tap/.tzx file → should show the identified entry with a link
- Drop an unknown file → should show "No matching tape found"
- Click "Identify another tape" → resets to dropzone
- Check file never leaves browser (Network tab: only the server action call with md5 + size)
- Verify non-supported extensions are rejected with helpful message

View File

@@ -0,0 +1,137 @@
# ZXDB Explorer — Missing Features & Gaps
Audit of the `/zxdb` pages against the ZXDB schema and existing data. Everything listed below is backed by tables already present in the Drizzle schema (`src/server/schema/zxdb.ts`) but not yet surfaced in the UI.
---
## Current Coverage
| Section | List page | Detail page | Facets/Filters |
|----------------|-----------|-------------|---------------------------------|
| Entries | Search | Full detail | genre, language, machinetype |
| Releases | Search | Downloads, scraps, files, magazine refs | — |
| Labels | Search | Authored/published entries, permissions, licenses | — |
| Magazines | Search | Issues list | — |
| Issues | via magazine | Magazine refs (reviews/references) | — |
| Genres | List | Entries by genre | — |
| Languages | List | Entries by language | — |
| Machine Types | List | Entries by type | — |
---
## Missing Top-Level Browse Pages
### 1. Countries
- **Tables:** `countries`, `labels.country_id`
- **Value:** Browse by country ("all software from Spain", "UK publishers").
### 2. Tools
- **Tables:** `tools`, `tooltypes`
- **Value:** Utilities, emulators, and development tools catalogued in ZXDB.
### 3. Features
- **Tables:** `features`
- **Value:** Hardware/software features (Multiface, Kempston joystick, etc.).
### 4. Topics
- **Tables:** `topics`, `topictypes`
- **Value:** Editorial/thematic groupings used by magazines.
### 5. Tags / Collections
- **Tables:** `tags`, `tagtypes`, `members`
- **Value:** Tags are shown per-entry but there is no top-level "browse by tag" page (e.g. all CSSCGC entries, compilations).
### 6. Licenses
- **Tables:** `licenses`, `licensetypes`, `relatedlicenses`, `licensors`
- **Value:** Shown per-entry detail but no "browse all licenses" hub (e.g. all games based on a Marvel license).
---
## Missing Cross-Links & Facets on Existing Pages
### 7. Magazine reviews on Entry detail
- Release detail shows magazine refs, but entry detail does **not** aggregate them.
- A user viewing an entry cannot see "reviewed in Crash #42, p.34" without drilling into each release.
### 8. Year / date filter on Entries
- ZXDB has `release_year` on releases. No year facet on the entries explorer.
- Users cannot browse "all games from 1985".
### 9. Availability type filter on Entries
- `availabletypes` API route exists but is not a facet on the entries explorer.
- Would allow filtering by "Never released", "MIA", etc.
### 10. Max players filter on Entries
- `entries.max_players` exists but is not filterable.
- Would enable "all multiplayer games".
### 11. Label type filter on Labels page
- `labeltypes` table exists and `roletypes` API is served.
- Cannot filter the labels list by type (person / company / team / magazine).
### 12. Country filter on Labels page
- Labels have `country_id` but no filter on the list page.
### 13. Country / language filter on Magazines page
- Magazine list has search but no country or language filter chips.
---
## Missing Data on Detail Pages
### 14. Entry detail: magazine reviews section
- `search_by_magrefs` is used in release detail but entry detail does not aggregate magazine references across all releases.
- Same issue as #7 — the entry page should show a combined reviews/references panel.
### 15. Label detail: country display
- Labels have `country_id` / `country2_id` but the detail page does not show them.
### 16. Label detail: Wikipedia / website links
- `labels.link_wikipedia` and `labels.link_site` exist but are not displayed on the label detail page.
### 17. Entry detail: related entries via same license
- Licenses are shown per-entry but there is no click-through to "other games with this license".
---
## Entirely Unsurfaced Datasets
### 18. NVGs
- **Table:** `nvgs`
- Historical download archive metadata. Not exposed anywhere.
### 19. SPEX entries / authors
- **Tables:** `spex_entries`, `spex_authors`
- No UI.
### 20. Awards
- **Table:** `zxsr_awards`, referenced by `magrefs.award_id`
- No awards browsing or display.
### 21. Review text
- **Table:** `zxsr_reviews` (`intro_text`, `review_text`)
- Magazine refs link to reviews by ID but the actual review text is never rendered.
### 22. Articles
- **Tables:** `articles`, `articletypes`
- No articles browsing.
---
## Navigation / UX Gaps
### 23. No discovery mechanism
- No "random entry", "on this day", or "featured" section. Common for large historic databases.
### 24. No stats / dashboard
- No summary counts ("ZXDB has X entries, Y labels, Z magazines"). Would anchor the landing page.
---
## Suggested Priority
| Priority | Items | Rationale |
|----------|-------|-----------|
| High | 7/14 (magazine refs on entry detail), 8 (year filter), 15-16 (label country + links) | Data exists, just not wired up. High user value. |
| Medium | 1 (countries), 5 (tags browse), 6 (licenses browse), 9 (availability filter), 24 (stats) | New pages but straightforward queries. |
| Low | 2-4 (tools/features/topics), 10-13 (additional filters), 17-22 (unsurfaced datasets), 23 (discovery) | Useful but niche or requires more design work. |

14
drizzle.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Config } from "drizzle-kit";
// This configuration is optional at the moment (no migrations run here),
// but kept for future schema generation if needed.
export default {
schema: "./src/server/schema/**/*.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
// Read from env at runtime when using drizzle-kit
url: process.env.ZXDB_URL!,
},
} satisfies Config;

41
example.env Normal file
View File

@@ -0,0 +1,41 @@
# System hostname for permalinks (mandatory)
HOSTNAME=localhost
# HTTP varient (mandatory)
PROTO=http
# ZXDB MySQL connection URL (mandatory)
# Example using a readonly user created by ZXDB scripts
# CREATE ROLE 'zxdb_readonly';
# GRANT SELECT, SHOW VIEW ON `zxdb`.* TO 'zxdb_readonly';
# See docs/ZXDB.md for full setup instructions (DB import, helper tables,
# readonly role, and environment validation notes).
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
# Base HTTP locations for CDN sources used by downloads.file_link
# When file_link starts with /zxdb, it will be fetched from ZXDB_REMOTE_FILEPATH
ZXDB_REMOTE_FILEPATH=https://zxdbfiles.com/
# When file_link starts with /public, it will be fetched from WOS_REMOTE_FILEPATH
# Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
WOS_REMOTE_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/
# Local mirror filesystem paths for downloads.
# Enabling these (and verifying existence) will show "Local Mirror" links.
# See docs/ZXDB.md for how prefixes are stripped and joined to these paths.
# ZXDB_LOCAL_FILEPATH=/path/to/local/zxdb/mirror
# WOS_LOCAL_FILEPATH=/path/to/local/wos/mirror
# Optional: Path prefixes to strip from database links before local matching.
# ZXDB_FILE_PREFIX=/zxdb/sinclair/
# WOS_FILE_PREFIX=/pub/sinclair/
# OIDC Authentication configuration
# OIDC_PROVIDER_URL=
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# Redis cache URL (e.g. redis://host:6379)
# CACHE_URL=
# SMTP mail URL (e.g. smtp://user:pass@host:587)
# MAIL_URL=

View File

@@ -1,7 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
sassOptions: {
// Silence noisy deprecation warnings coming from dependencies like Bootstrap.
quietDeps: true,
// Dart Sass deprecations still trigger via @import; explicitly mute them.
silenceDeprecations: [
"import",
"global-builtin",
"if-function",
"legacy-js-api",
"color-functions",
],
},
};
export default nextConfig;

View File

@@ -1,31 +1,42 @@
{
"name": "next-explorer",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "PORT=4000 next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"deploy": "bin/deploy.sh",
"deploy:branch": "bin/deploy.sh",
"setup:zxdb-local": "bin/setup-zxdb-local.sh",
"update:hashes": "node bin/update-software-hashes.mjs",
"export:hashes": "node bin/update-software-hashes.mjs --export-only",
"deploy-prod": "git push --set-upstream explorer.specnext.dev deploy",
"deploy-test": "git push --set-upstream test.explorer.specnext.dev test"
},
"dependencies": {
"bootstrap": "^5.3.8",
"next": "~15.5.7",
"dotenv": "^17.2.3",
"dotenv-expand": "^11.0.7",
"drizzle-orm": "^0.36.4",
"mysql2": "^3.16.0",
"next": "~15.5.9",
"react": "19.1.0",
"react-bootstrap": "^2.10.10",
"react-bootstrap-icons": "^1.11.6",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@types/node": "^20.19.25",
"@types/node": "^20.19.27",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"drizzle-kit": "^0.30.6",
"eslint": "^9.39.2",
"eslint-config-next": "15.5.4",
"sass": "^1.94.2"
"sass": "^1.97.0"
}
}

1102
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import { listAvailabletypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listAvailabletypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listCasetypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listCasetypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listCurrencies } from "@/server/repo/zxdb";
export async function GET() {
const items = await listCurrencies();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { env } from "@/env";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const source = searchParams.get("source");
const filePath = searchParams.get("path");
if (!source || !filePath) {
return new NextResponse("Missing source or path", { status: 400 });
}
let baseDir: string | undefined;
if (source === "zxdb") {
baseDir = env.ZXDB_LOCAL_FILEPATH;
} else if (source === "wos") {
baseDir = env.WOS_LOCAL_FILEPATH;
}
if (!baseDir) {
return new NextResponse("Invalid source or mirroring not enabled", { status: 400 });
}
// Security: Ensure path doesn't escape baseDir
const absolutePath = path.normalize(path.join(baseDir, filePath));
if (!absolutePath.startsWith(path.normalize(baseDir))) {
return new NextResponse("Forbidden", { status: 403 });
}
if (!fs.existsSync(absolutePath)) {
return new NextResponse("File not found", { status: 404 });
}
const stat = fs.statSync(absolutePath);
if (!stat.isFile()) {
return new NextResponse("Not a file", { status: 400 });
}
const fileBuffer = fs.readFileSync(absolutePath);
const fileName = path.basename(absolutePath);
const ext = path.extname(fileName).toLowerCase();
// Determine Content-Type
let contentType = "application/octet-stream";
if (ext === ".txt" || ext === ".nfo") {
contentType = "text/plain; charset=utf-8";
} else if (ext === ".png") {
contentType = "image/png";
} else if (ext === ".jpg" || ext === ".jpeg") {
contentType = "image/jpeg";
} else if (ext === ".gif") {
contentType = "image/gif";
} else if (ext === ".pdf") {
contentType = "application/pdf";
}
const isView = searchParams.get("view") === "1";
const disposition = isView ? "inline" : "attachment";
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `${disposition}; filename="${fileName}"`,
"Content-Length": stat.size.toString(),
},
});
}
export const runtime = "nodejs";

View 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";

View File

@@ -0,0 +1,10 @@
import { listFiletypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listFiletypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View 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";

View 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";

View 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";

View 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";

View 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";

View 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";

View 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";

View 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";

View File

@@ -0,0 +1,58 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { searchReleases } from "@/server/repo/zxdb";
const querySchema = z.object({
q: z.string().optional(),
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().max(100).optional(),
year: z.coerce.number().int().optional(),
sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(),
dLanguageId: z.string().trim().length(2).optional(),
dMachinetypeId: z.string().optional(),
filetypeId: z.coerce.number().int().positive().optional(),
schemetypeId: z.string().trim().length(2).optional(),
sourcetypeId: z.string().trim().length(1).optional(),
casetypeId: z.string().trim().length(1).optional(),
isDemo: z.coerce.boolean().optional(),
});
function parseIdList(value: string | undefined) {
if (!value) return undefined;
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({
q: searchParams.get("q") ?? undefined,
page: searchParams.get("page") ?? undefined,
pageSize: searchParams.get("pageSize") ?? undefined,
year: searchParams.get("year") ?? undefined,
sort: searchParams.get("sort") ?? undefined,
dLanguageId: searchParams.get("dLanguageId") ?? undefined,
dMachinetypeId: searchParams.get("dMachinetypeId") ?? undefined,
filetypeId: searchParams.get("filetypeId") ?? undefined,
schemetypeId: searchParams.get("schemetypeId") ?? undefined,
sourcetypeId: searchParams.get("sourcetypeId") ?? undefined,
casetypeId: searchParams.get("casetypeId") ?? undefined,
isDemo: searchParams.get("isDemo") ?? undefined,
});
if (!parsed.success) {
return new Response(JSON.stringify({ error: parsed.error.flatten() }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const dMachinetypeId = parseIdList(parsed.data.dMachinetypeId);
const data = await searchReleases({ ...parsed.data, dMachinetypeId });
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listRoletypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listRoletypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listSchemetypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listSchemetypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,63 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { searchEntries, getEntryFacets } from "@/server/repo/zxdb";
const querySchema = z.object({
q: z.string().optional(),
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().max(100).optional(),
genreId: z.coerce.number().int().positive().optional(),
languageId: z
.string()
.trim()
.length(2, "languageId must be a 2-char code")
.optional(),
machinetypeId: z.string().optional(),
year: z.coerce.number().int().optional(),
sort: z.enum(["title", "id_desc"]).optional(),
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
facets: z.coerce.boolean().optional(),
});
function parseIdList(value: string | undefined) {
if (!value) return undefined;
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({
q: searchParams.get("q") ?? undefined,
page: searchParams.get("page") ?? undefined,
pageSize: searchParams.get("pageSize") ?? undefined,
genreId: searchParams.get("genreId") ?? undefined,
languageId: searchParams.get("languageId") ?? undefined,
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
year: searchParams.get("year") ?? undefined,
sort: searchParams.get("sort") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
facets: searchParams.get("facets") ?? undefined,
});
if (!parsed.success) {
return new Response(
JSON.stringify({ error: parsed.error.flatten() }),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const machinetypeId = parseIdList(parsed.data.machinetypeId);
const searchParamsParsed = { ...parsed.data, machinetypeId };
const data = await searchEntries(searchParamsParsed);
const body = parsed.data.facets
? { ...data, facets: await getEntryFacets(searchParamsParsed) }
: data;
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});
}
// Ensure Node.js runtime (required for mysql2)
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listSourcetypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listSourcetypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -5,7 +5,7 @@ import NavbarClient from "@/components/Navbar";
export const metadata: Metadata = {
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 },
formatDetection: { email: false, address: false, telephone: false },
};

View File

@@ -1,15 +1,45 @@
import styles from "./page.module.css";
import Link from 'next/link';
import Link from "next/link";
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<div className="container-fluid py-4">
<div className="row g-3">
<div className="col-lg-6">
<div className="card h-100 shadow-sm">
<div className="card-body d-flex flex-column gap-3">
<div className="d-flex align-items-center gap-3">
<span className="bi bi-collection" style={{ fontSize: 40 }} aria-hidden />
<div>
<h1 className="h3 mb-1">ZXDB Explorer</h1>
<p className="text-secondary mb-0">Search entries, releases, magazines, and labels.</p>
</div>
</div>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-primary" href="/zxdb">Open ZXDB</Link>
</div>
<div className="text-secondary small">Built for deep linking and fast filters.</div>
</div>
</div>
</div>
<Link href="/registers">
Register Explorer
</Link>
</main>
<div className="col-lg-6">
<div className="card h-100 shadow-sm">
<div className="card-body d-flex flex-column gap-3">
<div className="d-flex align-items-center gap-3">
<span className="bi bi-cpu" style={{ fontSize: 40 }} aria-hidden />
<div>
<h2 className="h3 mb-1">NextReg Explorer</h2>
<p className="text-secondary mb-0">Browse Spectrum Next registers and bitfields.</p>
</div>
</div>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-primary" href="/registers">Open registers</Link>
</div>
<div className="text-secondary small">Parsed locally from official NextReg definitions.</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,12 @@
'use client';
"use client";
import { useEffect, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Register, RegisterAccess, Note } from '@/utils/register_parser';
import { Form, Container, Row, Table, OverlayTrigger, Tooltip } from 'react-bootstrap';
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Register, RegisterAccess, Note } from "@/utils/register_parser";
import { Form, Row, Table, OverlayTrigger, Tooltip } from "react-bootstrap";
import RegisterDetail from "@/app/registers/RegisterDetail";
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar";
interface RegisterBrowserProps {
registers: Register[];
@@ -73,7 +75,7 @@ export function renderAccess(access: RegisterAccess, extraNotes: Note[] = []) {
* @returns A React component that allows users to browse and search registers.
*/
export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
const [searchTerm, setSearchTerm] = useState('');
const [searchTerm, setSearchTerm] = useState("");
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
@@ -102,30 +104,42 @@ export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
router.replace(url, { scroll: false });
};
const filteredRegisters = registers.filter(register =>
register.search.includes(searchTerm.toLowerCase())
);
const filteredRegisters = useMemo(() => (
registers.filter((register) => register.search.includes(searchTerm.toLowerCase()))
), [registers, searchTerm]);
return (
<Container fluid>
<Form.Group className="mb-3">
<Form.Control
type="text"
placeholder="Search registers..."
value={searchTerm}
onChange={e => {
const v = e.target.value;
setSearchTerm(v);
updateQueryString(v);
}}
/>
</Form.Group>
<ExplorerLayout
title="NextReg Explorer"
subtitle={`${filteredRegisters.length.toLocaleString()} results`}
chips={searchTerm ? [`q: ${searchTerm}`] : []}
onClearChips={() => {
setSearchTerm("");
updateQueryString("");
}}
sidebar={(
<FilterSidebar>
<Form.Group>
<Form.Label className="form-label small text-secondary">Search</Form.Label>
<Form.Control
type="text"
placeholder="Search registers..."
value={searchTerm}
onChange={(e) => {
const v = e.target.value;
setSearchTerm(v);
updateQueryString(v);
}}
/>
</Form.Group>
</FilterSidebar>
)}
>
<Row>
{filteredRegisters.map(register => (
<RegisterDetail key={register.hex_address} register={register} />
{filteredRegisters.map((register) => (
<RegisterDetail key={register.hex_address} register={register} />
))}
</Row>
</Container>
</ExplorerLayout>
);
}

View File

@@ -0,0 +1,203 @@
import { ImageResponse } from 'next/og';
import { getRegisters } from '@/services/register.service';
export const runtime = 'nodejs';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
const buildRegisterSummaryLines = (register: { description: string; text: string; modes: { text: string }[] }) => {
const isInfoLine = (line: string) =>
line.length > 0 &&
!line.startsWith('//') &&
!line.startsWith('(R') &&
!line.startsWith('(W') &&
!line.startsWith('(R/W') &&
!line.startsWith('*') &&
!/^bits?\s+\d/i.test(line);
const normalizeLines = (raw: string) => {
const lines: string[] = [];
const rawLines = raw.split('\n');
for (const rawLine of rawLines) {
const trimmed = rawLine.trim();
if (!trimmed) {
if (lines.length > 0 && lines[lines.length - 1] !== '') {
lines.push('');
}
continue;
}
if (isInfoLine(trimmed)) {
lines.push(trimmed);
}
}
return lines;
};
const textLines = normalizeLines(register.text);
const modeLines = register.modes.flatMap(mode => normalizeLines(mode.text));
const descriptionLines = normalizeLines(register.description);
const combined: string[] = [];
const appendBlock = (block: string[]) => {
if (block.length === 0) return;
if (combined.length > 0 && combined[combined.length - 1] !== '') {
combined.push('');
}
combined.push(...block);
};
appendBlock(textLines);
appendBlock(modeLines);
appendBlock(descriptionLines);
const deduped: string[] = [];
const seen = new Set<string>();
for (const line of combined) {
if (!line) {
if (deduped.length > 0 && deduped[deduped.length - 1] !== '') {
deduped.push('');
}
continue;
}
if (seen.has(line)) {
continue;
}
seen.add(line);
deduped.push(line);
}
return deduped.length > 0 ? deduped : ['Spectrum Next register details and bit-level behavior.'];
};
const splitLongWord = (word: string, maxLineLength: number) => {
if (word.length <= maxLineLength) return [word];
const chunks: string[] = [];
for (let idx = 0; idx < word.length; idx += maxLineLength - 1) {
chunks.push(`${word.slice(idx, idx + maxLineLength - 1)}-`);
}
const last = chunks[chunks.length - 1];
chunks[chunks.length - 1] = last.endsWith('-') ? last.slice(0, -1) : last;
return chunks;
};
const wrapText = (text: string, maxLineLength: number) => {
const words = text
.split(/\s+/)
.filter(Boolean)
.flatMap(word => splitLongWord(word, maxLineLength));
const lines: string[] = [];
let current = '';
for (const word of words) {
const next = current ? `${current} ${word}` : word;
if (next.length > maxLineLength && current) {
lines.push(current);
current = word;
} else {
current = next;
}
}
if (current) {
lines.push(current);
}
return lines;
};
const wrapTextLines = (sourceLines: string[], maxLineLength: number, maxLines: number) => {
const output: string[] = [];
for (const line of sourceLines) {
if (output.length >= maxLines) break;
if (!line) {
if (output.length > 0 && output[output.length - 1] !== '') {
output.push('');
}
continue;
}
const wrapped = wrapText(line, maxLineLength);
for (const wrappedLine of wrapped) {
if (output.length >= maxLines) break;
output.push(wrappedLine);
}
}
return output.slice(0, maxLines);
};
export default async function Image({ params }: { params: Promise<{ hex: string }> }) {
const { hex } = await params;
const targetHex = decodeURIComponent(hex).toLowerCase();
const registers = await getRegisters();
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);
const title = register ? `${register.hex_address} ${register.name}` : 'Spectrum Next Register';
const summaryLinesSource = register
? buildRegisterSummaryLines(register)
: ['Register details not found.'];
const decAddress = register ? `Dec ${register.dec_address}` : '';
const titleLines = wrapTextLines([title], 32, 2);
const summaryLines = wrapTextLines(summaryLinesSource, 54, 6);
return new ImageResponse(
(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
background: 'linear-gradient(135deg, #3f1f6e 0%, #593196 55%, #7a4cc4 100%)',
color: '#f3f2ed',
fontFamily: 'Arial, sans-serif',
padding: '64px',
boxSizing: 'border-box',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', width: '100%' }}>
<div style={{ fontSize: '28px', letterSpacing: '2px', textTransform: 'uppercase', color: '#e8dcff' }}>
Spectrum Next Explorer
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
fontSize: '68px',
fontWeight: 700,
lineHeight: 1.05,
}}
>
{titleLines.map(line => (
<div key={line}>{line}</div>
))}
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
fontSize: '32px',
lineHeight: 1.35,
color: '#f7f1ff',
}}
>
{summaryLines.map(line => (
<div key={line}>{line}</div>
))}
</div>
<div style={{ marginTop: 'auto', fontSize: '26px', color: '#dacbff' }}>
{decAddress}
</div>
</div>
</div>
),
{
width: size.width,
height: size.height,
}
);
}

View File

@@ -1,13 +1,79 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { Register } from '@/utils/register_parser';
import RegisterDetail from '@/app/registers/RegisterDetail';
import {Container, Row} from "react-bootstrap";
import { getRegisters } from '@/services/register.service';
import {env} from "@/env";
export default async function RegisterDetailPage({ params }: { params: { hex: string } }) {
const buildRegisterSummary = (register: { description: string; text: string; modes: { text: string }[] }) => {
const trimLine = (line: string) => line.trim();
const isInfoLine = (line: string) =>
line.length > 0 &&
!line.startsWith('//') &&
!line.startsWith('(R') &&
!line.startsWith('(W') &&
!line.startsWith('(R/W') &&
!line.startsWith('*') &&
!/^bits?\s+\d/i.test(line);
const modeLines = register.modes.flatMap(mode => mode.text.split('\n')).map(trimLine).filter(isInfoLine);
const textLines = register.text.split('\n').map(trimLine).filter(isInfoLine);
const descriptionLines = register.description.split('\n').map(trimLine).filter(isInfoLine);
const rawSummary = [...textLines, ...modeLines, ...descriptionLines].join(' ').replace(/\s+/g, ' ').trim();
if (!rawSummary) return 'Spectrum Next register details and bit-level behavior.';
if (rawSummary.length <= 180) return rawSummary;
return `${rawSummary.slice(0, 177).trimEnd()}...`;
};
export async function generateMetadata({ params }: { params: Promise<{ hex: string }> }): Promise<Metadata> {
const registers = await getRegisters();
const targetHex = decodeURIComponent((await params).hex).toLowerCase();
const { hex } = await params;
const targetHex = decodeURIComponent(hex).toLowerCase();
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);
if (!register) {
return {
title: 'Register Not Found | Spectrum Next Explorer',
robots: { index: false, follow: false },
};
}
const summary = buildRegisterSummary(register);
const title = `${register.hex_address} ${register.name} | Spectrum Next Explorer`;
const imageUrl = `${env.PROTO}://${env.HOSTNAME}/registers/${register.hex_address}/opengraph-image`;
return {
title,
description: summary,
openGraph: {
title,
description: summary,
type: 'article',
url: `${env.PROTO}://${env.HOSTNAME}/registers/${register.hex_address}`,
images: [
{
url: imageUrl,
width: 1200,
height: 630,
alt: `${register.hex_address} ${register.name}`,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description: summary,
images: [imageUrl],
},
};
}
export default async function RegisterDetailPage({ params }: { params: Promise<{ hex: string }> }) {
const registers = await getRegisters();
const { hex } = await params;
const targetHex = decodeURIComponent(hex).toLowerCase();
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);

View File

@@ -6,7 +6,6 @@ export default async function RegistersPage() {
return (
<div className="container-fluid py-4">
<h1 className="mb-4">NextReg Explorer</h1>
<RegisterBrowser registers={registers} />
</div>
);

View File

@@ -0,0 +1,233 @@
"use client";
import { useState, useRef, useCallback } from "react";
import Link from "next/link";
import { computeMd5 } from "@/utils/md5";
import { identifyTape } from "./actions";
import type { TapeMatch } from "@/server/repo/zxdb";
const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
type State =
| { kind: "idle" }
| { kind: "hashing" }
| { kind: "identifying" }
| { kind: "results"; matches: TapeMatch[]; fileName: string }
| { kind: "not-found"; fileName: string }
| { kind: "error"; message: string };
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function TapeIdentifier() {
const [state, setState] = useState<State>({ kind: "idle" });
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const processFile = useCallback(async (file: File) => {
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
if (!SUPPORTED_EXTS.includes(ext)) {
setState({
kind: "error",
message: `Unsupported file type "${ext}". Supported: ${SUPPORTED_EXTS.join(", ")}`,
});
return;
}
setState({ kind: "hashing" });
try {
const md5 = await computeMd5(file);
setState({ kind: "identifying" });
const matches = await identifyTape(md5, file.size);
if (matches.length > 0) {
setState({ kind: "results", matches, fileName: file.name });
} else {
setState({ kind: "not-found", fileName: file.name });
}
} catch {
setState({ kind: "error", message: "Something went wrong. Please try again." });
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
},
[processFile]
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
// Reset so re-selecting the same file triggers change
e.target.value = "";
},
[processFile]
);
const reset = useCallback(() => {
setState({ kind: "idle" });
}, []);
// Dropzone view (idle, hashing, identifying, error)
if (state.kind === "results" || state.kind === "not-found") {
return (
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
Tape Identifier
</h5>
{state.kind === "results" ? (
<>
<p className="text-secondary mb-3">
<strong>{state.fileName}</strong> matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:
</p>
{state.matches.map((m) => (
<div key={m.downloadId} className="card border mb-3">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
<h6 className="card-title mb-0">
<Link href={`/zxdb/entries/${m.entryId}`} className="text-decoration-none">
{m.entryTitle}
</Link>
</h6>
{m.releaseYear && (
<span className="badge text-bg-secondary ms-2">{m.releaseYear}</span>
)}
</div>
{(m.authors.length > 0 || m.genre || m.machinetype) && (
<div className="d-flex flex-wrap gap-2 mb-2 small text-secondary">
{m.authors.length > 0 && (
<span><span className="bi bi-person me-1" aria-hidden />{m.authors.join(", ")}</span>
)}
{m.genre && (
<span><span className="bi bi-tag me-1" aria-hidden />{m.genre}</span>
)}
{m.machinetype && (
<span><span className="bi bi-cpu me-1" aria-hidden />{m.machinetype}</span>
)}
</div>
)}
<table className="table table-sm table-borderless mb-2 small" style={{ maxWidth: 500 }}>
<tbody>
<tr>
<td className="text-secondary ps-0" style={{ width: 90 }}>File</td>
<td className="font-monospace">{m.innerPath}</td>
</tr>
<tr>
<td className="text-secondary ps-0">Size</td>
<td>{formatBytes(m.sizeBytes)}</td>
</tr>
<tr>
<td className="text-secondary ps-0">MD5</td>
<td className="font-monospace">{m.md5}</td>
</tr>
<tr>
<td className="text-secondary ps-0">CRC32</td>
<td className="font-monospace">{m.crc32}</td>
</tr>
</tbody>
</table>
<Link
href={`/zxdb/entries/${m.entryId}`}
className="btn btn-outline-primary btn-sm"
>
View entry <span className="bi bi-arrow-right ms-1" aria-hidden />
</Link>
</div>
</div>
))}
</>
) : (
<p className="text-secondary mb-3">
No matching tape found in ZXDB for <strong>{state.fileName}</strong>.
</p>
)}
<button className="btn btn-outline-primary btn-sm" onClick={reset}>
Identify another tape
</button>
</div>
</div>
);
}
const isProcessing = state.kind === "hashing" || state.kind === "identifying";
return (
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
Tape Identifier
</h5>
<div
className={`rounded-3 p-4 text-center ${dragOver ? "bg-primary bg-opacity-10 border-primary" : "border-secondary border-opacity-25"}`}
style={{
border: "2px dashed",
cursor: isProcessing ? "wait" : "pointer",
transition: "background-color 0.15s, border-color 0.15s",
}}
onDragOver={(e) => {
e.preventDefault();
if (!isProcessing) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={isProcessing ? (e) => e.preventDefault() : handleDrop}
onClick={isProcessing ? undefined : () => inputRef.current?.click()}
>
{isProcessing ? (
<div className="py-2">
<div className="spinner-border spinner-border-sm text-primary me-2" role="status" />
<span className="text-secondary">
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
</span>
</div>
) : (
<>
<div className="mb-2">
<span className="bi bi-cloud-arrow-up" style={{ fontSize: 32, opacity: 0.5 }} aria-hidden />
</div>
<p className="mb-1 text-secondary">
Drop a tape file to identify it
</p>
<p className="mb-0 small text-secondary">
{SUPPORTED_EXTS.join(" ")} &mdash; or{" "}
<span className="text-primary" style={{ textDecoration: "underline", cursor: "pointer" }}>
choose file
</span>
</p>
</>
)}
<input
ref={inputRef}
type="file"
accept={SUPPORTED_EXTS.join(",")}
className="d-none"
onChange={handleFileInput}
/>
</div>
{state.kind === "error" && (
<div className="alert alert-warning mt-3 mb-0 py-2 small">
{state.message}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
type Item = {
id: number;
title: string;
isXrated: number;
machinetypeId: number | null;
machinetypeName?: string | null;
languageId: string | null;
languageName?: string | null;
};
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
export default function ZxdbExplorer({
initial,
initialGenres,
initialLanguages,
initialMachines,
}: {
initial?: Paged<Item>;
initialGenres?: { id: number; name: string }[];
initialLanguages?: { id: string; name: string }[];
initialMachines?: { id: number; name: string }[];
}) {
const [q, setQ] = useState("");
const [page, setPage] = useState(initial?.page ?? 1);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []);
const [genreId, setGenreId] = useState<number | "">("");
const [languageId, setLanguageId] = useState<string | "">("");
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
const [year, setYear] = useState<string>("");
const [sort, setSort] = useState<"title" | "id_desc">("id_desc");
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
async function fetchData(query: string, p: number) {
setLoading(true);
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("page", String(p));
params.set("pageSize", String(pageSize));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
if (year !== "") params.set("year", year);
if (sort) params.set("sort", sort);
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json: Paged<Item> = await res.json();
setData(json);
} catch (e) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}
useEffect(() => {
// When navigating via Next.js Links that change ?page=, SSR provides new `initial`.
// Sync local state from new SSR payload so the list and counter update immediately
// without an extra client fetch.
if (initial) {
setData(initial);
setPage(initial.page);
}
}, [initial]);
useEffect(() => {
// Avoid immediate client fetch on first paint if server provided initial data for this exact state
const initialPage = initial?.page ?? 1;
if (
initial &&
page === initialPage &&
q === "" &&
genreId === "" &&
languageId === "" &&
machinetypeId === "" &&
year === "" &&
sort === "id_desc"
) {
return;
}
fetchData(q, page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeId, year, sort]);
// Load filter lists on mount only if not provided by server
useEffect(() => {
if (initialGenres && initialLanguages && initialMachines) return;
async function loadLists() {
try {
const [g, l, m] = await Promise.all([
fetch("/api/zxdb/genres", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()),
]);
setGenres(g.items ?? []);
setLanguages(l.items ?? []);
setMachines(m.items ?? []);
} catch {}
}
loadLists();
}, [initialGenres, initialLanguages, initialMachines]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setPage(1);
fetchData(q, 1);
}
return (
<div>
<h1 className="mb-3">ZXDB Explorer</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input
type="text"
className="form-control"
placeholder="Search titles..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="col-auto">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div>
<div className="col-auto">
<select className="form-select" value={genreId} onChange={(e) => setGenreId(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Genre</option>
{genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={languageId} onChange={(e) => setLanguageId(e.target.value)}>
<option value="">Language</option>
{languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={machinetypeId} onChange={(e) => setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Machine</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div className="col-auto">
<input
type="number"
className="form-control"
style={{ width: 100 }}
placeholder="Year"
value={year}
onChange={(e) => setYear(e.target.value)}
/>
</div>
<div className="col-auto">
<select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as "title" | "id_desc")}>
<option value="title">Sort: Title</option>
<option value="id_desc">Sort: Newest</option>
</select>
</div>
{loading && (
<div className="col-auto text-secondary">Loading...</div>
)}
</form>
<div className="mt-3">
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{width: 80}}>ID</th>
<th>Title</th>
<th style={{width: 160}}>Machine</th>
<th style={{width: 120}}>Language</th>
</tr>
</thead>
<tbody>
{data.items.map((it) => (
<tr key={it.id}>
<td>{it.id}</td>
<td>
<Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link>
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="d-flex align-items-center gap-2 mt-2">
<span>
Page {data?.page ?? 1} / {totalPages}
</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={`/zxdb?page=${Math.max(1, (data?.page ?? 1) - 1)}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb?page=${Math.min(totalPages, (data?.page ?? 1) + 1)}`}
>
Next
</Link>
</div>
</div>
<hr />
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</Link>
</div>
</div>
);
}

22
src/app/zxdb/actions.ts Normal file
View File

@@ -0,0 +1,22 @@
"use server";
import { lookupByMd5, type TapeMatch } from "@/server/repo/zxdb";
export async function identifyTape(
md5: string,
sizeBytes: number
): Promise<TapeMatch[]> {
// Validate input shape
if (!/^[0-9a-f]{32}$/i.test(md5)) return [];
if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return [];
const matches = await lookupByMd5(md5);
// If multiple matches and size can disambiguate, filter by size
if (matches.length > 1) {
const bySz = matches.filter((m) => m.sizeBytes === sizeBytes);
if (bySz.length > 0) return bySz;
}
return matches;
}

View File

@@ -0,0 +1,18 @@
"use client";
import Link from "next/link";
type Props = {
id: number;
title?: string;
className?: string;
};
export default function EntryLink({ id, title, className }: Props) {
const text = typeof title === "string" && title.length > 0 ? title : `#${id}`;
return (
<Link href={`/zxdb/entries/${id}`} className={className}>
{text}
</Link>
);
}

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
type Crumb = {
label: string;
href?: string;
};
export default function ZxdbBreadcrumbs({ items }: { items: Crumb[] }) {
if (items.length === 0) return null;
const lastIndex = items.length - 1;
return (
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
{items.map((item, index) => {
const isActive = index === lastIndex || !item.href;
return (
<li key={`${item.label}-${index}`} className={`breadcrumb-item${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
{isActive ? item.label : <Link href={item.href ?? "#"}>{item.label}</Link>}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,471 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar";
import MultiSelectChips from "@/components/explorer/MultiSelectChips";
const preferredMachineIds = [27, 26, 8, 9];
type Item = {
id: number;
title: string;
isXrated: number;
genreId: number | null;
genreName?: string | null;
machinetypeId: number | null;
machinetypeName?: string | null;
languageId: string | null;
languageName?: string | null;
};
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
type EntryFacets = {
genres: { id: number; name: string; count: number }[];
languages: { id: string; name: string; count: number }[];
machinetypes: { id: number; name: string; count: number }[];
flags: { hasAliases: number; hasOrigins: number };
};
export default function EntriesExplorer({
initial,
initialGenres,
initialLanguages,
initialMachines,
initialFacets,
initialUrlState,
}: {
initial?: Paged<Item>;
initialGenres?: { id: number; name: string }[];
initialLanguages?: { id: string; name: string }[];
initialMachines?: { id: number; name: string }[];
initialFacets?: EntryFacets | null;
initialUrlState?: {
q: string;
page: number;
genreId: string | number | "";
languageId: string | "";
machinetypeId: string;
sort: "title" | "id_desc";
scope?: SearchScope;
};
}) {
const parseMachineIds = (value?: string) => {
if (!value) return preferredMachineIds.slice();
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : preferredMachineIds.slice();
};
const router = useRouter();
const pathname = usePathname();
const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? "");
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []);
const [genreId, setGenreId] = useState<number | "">(
initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : ""
);
const [languageId, setLanguageId] = useState<string | "">(initialUrlState?.languageId ?? "");
const [machinetypeIds, setMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.machinetypeId));
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null);
const preferredMachineNames = useMemo(() => {
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`);
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
}, [machines]);
const orderedMachines = useMemo(() => {
const seen = new Set(preferredMachineIds);
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[];
const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest];
}, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
const activeFilters = useMemo(() => {
const chips: string[] = [];
if (appliedQ) chips.push(`q: ${appliedQ}`);
if (genreId !== "") {
const name = genres.find((g) => g.id === Number(genreId))?.name ?? `#${genreId}`;
chips.push(`genre: ${name}`);
}
if (languageId !== "") {
const name = languages.find((l) => l.id === languageId)?.name ?? languageId;
chips.push(`lang: ${name}`);
}
if (machinetypeIds.length > 0) {
const names = machinetypeIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
chips.push(`machine: ${names.join(", ")}`);
}
if (scope === "title_aliases") chips.push("scope: titles + aliases");
if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins");
return chips;
}, [appliedQ, genreId, languageId, machinetypeIds, scope, genres, languages, machines]);
function updateUrl(nextPage = page) {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(nextPage));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname);
}
async function fetchData(query: string, p: number, withFacets: boolean) {
setLoading(true);
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("page", String(p));
params.set("pageSize", String(pageSize));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
if (withFacets) params.set("facets", "true");
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = await res.json();
setData(json);
if (withFacets && json.facets) {
setFacets(json.facets as EntryFacets);
}
} catch (e) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}
// Sync from SSR payload on navigation
useEffect(() => {
if (initial) {
setData(initial);
setPage(initial.page);
}
}, [initial]);
// Client fetch when filters/paging/sort change; also keep URL in sync
useEffect(() => {
// Avoid extra fetch if SSR already matches this exact default state
const initialPage = initial?.page ?? 1;
if (
initial &&
page === initialPage &&
(initialUrlState?.q ?? "") === appliedQ &&
(initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) &&
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
parseMachineIds(initialUrlState?.machinetypeId).join(",") === machinetypeIds.join(",") &&
sort === (initialUrlState?.sort ?? "id_desc") &&
(initialUrlState?.scope ?? "title") === scope
) {
updateUrl(page);
return;
}
updateUrl(page);
fetchData(appliedQ, page, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]);
// Load filter lists on mount only if not provided by server
useEffect(() => {
if (initialGenres && initialLanguages && initialMachines) return;
async function loadLists() {
try {
const [g, l, m] = await Promise.all([
fetch("/api/zxdb/genres", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()),
]);
setGenres(g.items ?? []);
setLanguages(l.items ?? []);
setMachines(m.items ?? []);
} catch {}
}
loadLists();
}, [initialGenres, initialLanguages, initialMachines]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setAppliedQ(q);
setPage(1);
}
function resetFilters() {
setQ("");
setAppliedQ("");
setGenreId("");
setLanguageId("");
setMachinetypeIds(preferredMachineIds.slice());
setSort("id_desc");
setScope("title");
setPage(1);
}
const prevHref = useMemo(() => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1)));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
const nextHref = useMemo(() => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1)));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Entries" },
]}
/>
<ExplorerLayout
title="Entries"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
chips={activeFilters}
onClearChips={resetFilters}
sidebar={(
<FilterSidebar>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input
type="text"
className="form-control"
placeholder="Search titles..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="d-grid">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div>
<div>
<label className="form-label small text-secondary">Genre</label>
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">All genres</option>
{genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Language</label>
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Machine</label>
<MultiSelectChips
options={machineOptions}
selected={machinetypeIds}
onToggle={(id) => {
setMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
const order = machineOptions.map((item) => item.id);
return order.filter((value) => next.has(value));
});
setPage(1);
}}
/>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">Sort</label>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
<option value="title">Title (AZ)</option>
<option value="id_desc">Newest</option>
</select>
</div>
<div>
<label className="form-label small text-secondary">Search scope</label>
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
<option value="title">Titles</option>
<option value="title_aliases">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select>
</div>
{facets && (
<div>
<div className="text-secondary small mb-1">Facets</div>
<div className="d-flex flex-wrap gap-2">
<button
type="button"
className={`btn btn-sm ${scope === "title_aliases" ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => { setScope("title_aliases"); setPage(1); }}
disabled={facets.flags.hasAliases === 0}
title="Show results that match aliases"
>
Has aliases ({facets.flags.hasAliases})
</button>
<button
type="button"
className={`btn btn-sm ${scope === "title_aliases_origins" ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => { setScope("title_aliases_origins"); setPage(1); }}
disabled={facets.flags.hasOrigins === 0}
title="Show results that match origins"
>
Has origins ({facets.flags.hasOrigins})
</button>
</div>
</div>
)}
{loading && <div className="text-secondary small">Loading...</div>}
</form>
</FilterSidebar>
)}
>
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 160 }}>Genre</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
{data.items.map((it) => (
<tr key={it.id}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.genreId != null ? (
it.genreName ? (
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link>
) : (
<span>{it.genreId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4">
<span>Page {data?.page ?? 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={prevHref}
onClick={(e) => {
if (!data || data.page <= 1) return;
e.preventDefault();
setPage((p) => Math.max(1, p - 1));
}}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={nextHref}
onClick={(e) => {
if (!data || data.page >= totalPages) return;
e.preventDefault();
setPage((p) => Math.min(totalPages, p + 1));
}}
>
Next
</Link>
</div>
</div>
<hr />
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,927 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type Label = { id: number; name: string; labeltypeId: string | null };
export type EntryDetailData = {
id: number;
title: string;
isXrated: number;
machinetype: { id: number | null; name: string | null };
language: { id: string | null; name: string | null };
genre: { id: number | null; name: string | null };
authors: Label[];
publishers: Label[];
licenses?: {
id: number;
name: string;
type: { id: string; name: string | null };
isOfficial: boolean;
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
relations?: {
direction: "from" | "to";
type: { id: string; name: string | null };
entry: { id: number; title: string | null };
}[];
tags?: {
id: number;
name: string;
type: { id: string; name: string | null };
category: { id: number | null; name: string | null };
memberSeq: number | null;
link: string | null;
comments: string | null;
}[];
ports?: {
id: number;
title: string | null;
platform: { id: number; name: string | null };
isOfficial: boolean;
linkSystem: string | null;
}[];
remakes?: {
id: number;
title: string;
fileLink: string;
fileDate: string | null;
fileSize: number | null;
authors: string | null;
platforms: string | null;
remakeYears: string | null;
remakeStatus: string | null;
}[];
scores?: {
website: { id: number; name: string | null };
score: number;
votes: number;
}[];
notes?: {
id: number;
type: { id: string; name: string | null };
text: string;
}[];
origins?: {
type: { id: string; name: string | null };
libraryTitle: string;
publication: string | null;
containerId: number | null;
issueId: number | null;
issue: { id: number; magazineId: number | null; magazineTitle: string | null } | null;
date: { year: number | null; month: number | null; day: number | null };
}[];
// extra fields for richer details
maxPlayers?: number;
availabletypeId?: string | null;
withoutLoadScreen?: number;
withoutInlay?: number;
issueId?: number | null;
files?: {
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
type: { id: number; name: string };
}[];
// Flat downloads by entry_id
downloadsFlat?: {
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
isDemo: boolean;
type: { id: number; name: string };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
scheme: { id: string | null; name: string | null };
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
releaseSeq: number;
localLink?: string | null;
}[];
releases?: {
releaseSeq: number;
type: { id: string | null; name: string | null };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
year: number | null;
comments: string | null;
downloads: {
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
isDemo: boolean;
type: { id: number; name: string };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
scheme: { id: string | null; name: string | null };
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
localLink?: string | null;
}[];
}[];
// Additional relationships
aliases?: { releaseSeq: number; languageId: string; title: string }[];
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
magazineRefs?: {
id: number;
issueId: number;
magazineId: number | null;
magazineName: string | null;
referencetypeId: number;
referencetypeName: string | null;
page: number;
isOriginal: number;
scoreGroup: string;
issue: {
dateYear: number | null;
dateMonth: number | null;
dateDay: number | null;
volume: number | null;
number: number | null;
special: string | null;
supplement: string | null;
};
}[];
};
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null);
const groupedDownloads = useMemo(() => {
if (!data?.downloadsFlat) return [];
const groups = new Map<string, EntryDetailData["downloadsFlat"]>();
for (const d of data.downloadsFlat) {
const type = d.type.name;
const arr = groups.get(type) ?? [];
arr.push(d);
groups.set(type, arr);
}
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [data?.downloadsFlat]);
if (!data) return <div className="alert alert-warning">Not found</div>;
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Entries", href: "/zxdb/entries" },
{ label: data.title },
]}
/>
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">{data.title}</h1>
{data.genre.name && (
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
{data.genre.name}
</Link>
)}
{data.language.name && (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
{data.language.name}
</Link>
)}
{data.machinetype.name && (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
{data.machinetype.name}
</Link>
)}
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
</div>
<div className="row g-3 mt-2">
<div className="col-lg-4">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Entry Summary</h5>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<tbody>
<tr>
<th style={{ width: 180 }}>ID</th>
<td>{data.id}</td>
</tr>
<tr>
<th>Title</th>
<td>{data.title}</td>
</tr>
<tr>
<th>Machine</th>
<td>
{data.machinetype.id != null ? (
data.machinetype.name ? (
<Link href={`/zxdb/machinetypes/${data.machinetype.id}`}>{data.machinetype.name}</Link>
) : (
<span>#{data.machinetype.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<th>Language</th>
<td>
{data.language.id ? (
data.language.name ? (
<Link href={`/zxdb/languages/${data.language.id}`}>{data.language.name}</Link>
) : (
<span>{data.language.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<th>Genre</th>
<td>
{data.genre.id ? (
data.genre.name ? (
<Link href={`/zxdb/genres/${data.genre.id}`}>{data.genre.name}</Link>
) : (
<span>#{data.genre.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
{typeof data.maxPlayers !== "undefined" && (
<tr>
<th>Max Players</th>
<td>{data.maxPlayers}</td>
</tr>
)}
{typeof data.availabletypeId !== "undefined" && (
<tr>
<th>Available Type</th>
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
</tr>
)}
{typeof data.withoutLoadScreen !== "undefined" && (
<tr>
<th>Without Load Screen</th>
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
</tr>
)}
{typeof data.withoutInlay !== "undefined" && (
<tr>
<th>Without Inlay</th>
<td>{data.withoutInlay ? "Yes" : "No"}</td>
</tr>
)}
{typeof data.issueId !== "undefined" && (
<tr>
<th>Issue</th>
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">People</h5>
<div className="row g-3">
<div className="col-12">
<div className="text-secondary small mb-1">Authors</div>
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
{data.authors.length > 0 && (
<ul className="list-unstyled mb-0">
{data.authors.map((a) => (
<li key={a.id}>
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
</li>
))}
</ul>
)}
</div>
<div className="col-12">
<div className="text-secondary small mb-1">Publishers</div>
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>}
{data.publishers.length > 0 && (
<ul className="list-unstyled mb-0">
{data.publishers.map((p) => (
<li key={p.id}>
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Magazine References</h5>
{(!data.magazineRefs || data.magazineRefs.length === 0) && <div className="text-secondary">No magazine references recorded</div>}
{data.magazineRefs && data.magazineRefs.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Magazine</th>
<th style={{ width: 140 }}>Issue</th>
<th style={{ width: 140 }}>Type</th>
<th style={{ width: 120 }}>Page</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{data.magazineRefs.map((m) => (
<tr key={m.id}>
<td>
{m.magazineId ? (
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName}</Link>
) : (
<span>{m.magazineName}</span>
)}
</td>
<td>
<Link href={`/zxdb/issues/${m.issueId}`}>
{m.issue.dateYear ? `${m.issue.dateYear} ` : ""}
{m.issue.number ? `#${m.issue.number}` : ""}
{m.issue.special ? ` (${m.issue.special})` : ""}
</Link>
</td>
<td>{m.referencetypeName}</td>
<td>{m.page > 0 ? m.page : "-"}</td>
<td>{m.scoreGroup || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body d-flex flex-wrap gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
</div>
</div>
</div>
<div className="col-lg-8">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Downloads</h5>
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{groupedDownloads.map(([type, items]) => (
<div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Link</th>
<th style={{ width: 100 }} className="text-end">Size</th>
<th style={{ width: 180 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{items?.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
const fileName = d.link.split("/").pop() || "file";
const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
return (
<tr key={d.id}>
<td>
<div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : (
<span className="text-break small">{d.link}</span>
)}
{canPreview && (
<button
className="btn btn-xs btn-outline-info py-0 px-1"
style={{ fontSize: "0.6rem" }}
onClick={() => setViewer({ url: d.localLink!, title: fileName })}
>
Preview
</button>
)}
</div>
{d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
<td>
<div className="d-flex gap-1 flex-wrap">
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{d.language.name && (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
)}
{d.machinetype.name && (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
)}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
<Link className="badge text-bg-light text-decoration-none" href={`/zxdb/releases/${data.id}/${d.releaseSeq}`}>
rel #{d.releaseSeq}
</Link>
</div>
</td>
<td className="small">{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Releases</h5>
{(!data.releases || data.releases.length === 0) && <div className="text-secondary">No releases recorded</div>}
{data.releases && data.releases.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>Release #</th>
<th style={{ width: 120 }}>Year</th>
<th>Downloads</th>
</tr>
</thead>
<tbody>
{data.releases.map((r) => (
<tr key={r.releaseSeq}>
<td>
<Link href={`/zxdb/releases/${data.id}/${r.releaseSeq}`}>#{r.releaseSeq}</Link>
</td>
<td>{r.year ?? <span className="text-secondary">-</span>}</td>
<td>{r.downloads.length}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Origins</h5>
{(!data.origins || data.origins.length === 0) && <div className="text-secondary">No origins recorded</div>}
{data.origins && data.origins.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Title</th>
<th>Publication</th>
<th style={{ width: 200 }}>Issue</th>
<th style={{ width: 140 }}>Date</th>
</tr>
</thead>
<tbody>
{data.origins.map((o, idx) => {
const dateParts = [o.date.year, o.date.month, o.date.day]
.filter((v) => typeof v === "number" && Number.isFinite(v))
.map((v, i) => (i === 0 ? String(v) : String(v).padStart(2, "0")));
const dateText = dateParts.length ? dateParts.join("/") : "-";
return (
<tr key={`${o.type.id}-${idx}`}>
<td>{o.type.name ?? o.type.id}</td>
<td>{o.libraryTitle}</td>
<td>{o.publication ?? <span className="text-secondary">-</span>}</td>
<td>
{o.issue ? (
<div className="d-flex flex-column">
<Link href={`/zxdb/issues/${o.issue.id}`}>Issue #{o.issue.id}</Link>
{o.issue.magazineId != null && (
<Link className="text-secondary small" href={`/zxdb/magazines/${o.issue.magazineId}`}>
{o.issue.magazineTitle ?? `Magazine #${o.issue.magazineId}`}
</Link>
)}
</div>
) : o.containerId ? (
<span>Container #{o.containerId}</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>{dateText}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Relations</h5>
{(!data.relations || data.relations.length === 0) && <div className="text-secondary">No relations recorded</div>}
{data.relations && data.relations.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 90 }}>Direction</th>
<th style={{ width: 160 }}>Type</th>
<th>Entry</th>
</tr>
</thead>
<tbody>
{data.relations.map((r, idx) => (
<tr key={`${r.entry.id}-${r.type.id}-${idx}`}>
<td>{r.direction === "from" ? "From" : "To"}</td>
<td>{r.type.name ?? r.type.id}</td>
<td>
<Link href={`/zxdb/entries/${r.entry.id}`}>
{r.entry.title ?? `Entry #${r.entry.id}`}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Tags / Members</h5>
{(!data.tags || data.tags.length === 0) && <div className="text-secondary">No tags recorded</div>}
{data.tags && data.tags.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Tag</th>
<th style={{ width: 140 }}>Type</th>
<th style={{ width: 140 }}>Category</th>
<th style={{ width: 120 }}>Member Seq</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{data.tags.map((t) => (
<tr key={`${t.id}-${t.category.id ?? "none"}`}>
<td>{t.name}</td>
<td>{t.type.name ?? t.type.id}</td>
<td>{t.category.name ?? (t.category.id != null ? `#${t.category.id}` : "-")}</td>
<td>{t.memberSeq ?? <span className="text-secondary">-</span>}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{t.link && (
<a href={t.link} target="_blank" rel="noreferrer">Link</a>
)}
{t.comments && <span className="text-secondary">{t.comments}</span>}
{!t.link && !t.comments && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Ports</h5>
{(!data.ports || data.ports.length === 0) && <div className="text-secondary">No ports recorded</div>}
{data.ports && data.ports.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Title</th>
<th style={{ width: 160 }}>Platform</th>
<th style={{ width: 120 }}>Official</th>
<th>Link</th>
</tr>
</thead>
<tbody>
{data.ports.map((p) => (
<tr key={p.id}>
<td>{p.title ?? <span className="text-secondary">-</span>}</td>
<td>{p.platform.name ?? `#${p.platform.id}`}</td>
<td>{p.isOfficial ? "Yes" : "No"}</td>
<td>
{p.linkSystem ? (
<a href={p.linkSystem} target="_blank" rel="noreferrer">Link</a>
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Remakes</h5>
{(!data.remakes || data.remakes.length === 0) && <div className="text-secondary">No remakes recorded</div>}
{data.remakes && data.remakes.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Title</th>
<th style={{ width: 160 }}>Platforms</th>
<th style={{ width: 140 }}>Years</th>
<th style={{ width: 140 }}>File</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{data.remakes.map((r) => (
<tr key={r.id}>
<td>{r.title}</td>
<td>{r.platforms ?? <span className="text-secondary">-</span>}</td>
<td>{r.remakeYears ?? <span className="text-secondary">-</span>}</td>
<td>
{r.fileLink ? (
<a href={r.fileLink} target="_blank" rel="noreferrer">File</a>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>{r.remakeStatus ?? r.authors ?? <span className="text-secondary">-</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Scores</h5>
{(!data.scores || data.scores.length === 0) && <div className="text-secondary">No scores recorded</div>}
{data.scores && data.scores.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 120 }}>Score</th>
<th style={{ width: 120 }}>Votes</th>
</tr>
</thead>
<tbody>
{data.scores.map((s, idx) => (
<tr key={`${s.website.id}-${idx}`}>
<td>{s.website.name ?? `#${s.website.id}`}</td>
<td>{s.score}</td>
<td>{s.votes}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Notes</h5>
{(!data.notes || data.notes.length === 0) && <div className="text-secondary">No notes recorded</div>}
{data.notes && data.notes.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 140 }}>Type</th>
<th>Text</th>
</tr>
</thead>
<tbody>
{data.notes.map((n) => (
<tr key={n.id}>
<td>{n.type.name ?? n.type.id}</td>
<td>{n.text}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Aliases</h5>
{(!data.aliases || data.aliases.length === 0) && <div className="text-secondary">No aliases</div>}
{data.aliases && data.aliases.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 90 }}>Release #</th>
<th style={{ width: 120 }}>Language</th>
<th>Title</th>
</tr>
</thead>
<tbody>
{data.aliases.map((a, idx) => (
<tr key={`${a.releaseSeq}-${a.languageId}-${idx}`}>
<td>
<Link href={`/zxdb/releases/${data.id}/${a.releaseSeq}`}>#{a.releaseSeq}</Link>
</td>
<td>{a.languageId}</td>
<td>{a.title}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Licenses</h5>
{(!data.licenses || data.licenses.length === 0) && <div className="text-secondary">No licenses linked</div>}
{data.licenses && data.licenses.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th style={{ width: 140 }}>Type</th>
<th style={{ width: 120 }}>Official</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{data.licenses.map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td>{l.type.name ?? l.type.id}</td>
<td>{l.isOfficial ? "Yes" : "No"}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{l.linkWikipedia && (
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
)}
{l.linkSite && (
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
)}
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Web links</h5>
{(!data.webrefs || data.webrefs.length === 0) && <div className="text-secondary">No web links</div>}
{data.webrefs && data.webrefs.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 120 }}>Language</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{data.webrefs.map((w, idx) => (
<tr key={`${w.website.id}-${idx}`}>
<td>
{w.website.link ? (
<a href={w.website.link} target="_blank" rel="noopener noreferrer">{w.website.name}</a>
) : (
<span>{w.website.name}</span>
)}
</td>
<td>{w.languageId}</td>
<td>
<a href={w.link} target="_blank" rel="noopener noreferrer">{w.link}</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<h5 className="card-title">Files</h5>
{(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>}
{data.files && data.files.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 260 }}>MD5</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.files.map((f) => {
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
return (
<tr key={f.id}>
<td><span className="badge text-bg-secondary">{f.type.name}</span></td>
<td>
{isHttp ? (
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
) : (
<span>{f.link}</span>
)}
</td>
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
<td><code>{f.md5 ?? "-"}</code></td>
<td>{f.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div>
);
}

View 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} />;
}

View File

@@ -0,0 +1,69 @@
import EntriesExplorer from "./EntriesExplorer";
import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb";
export const metadata = {
title: "ZXDB Entries",
};
export const dynamic = "force-dynamic";
function parseIdList(value: string | string[] | undefined) {
if (!value) return undefined;
const raw = Array.isArray(value) ? value.join(",") : value;
const ids = raw
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const genreId = (Array.isArray(sp.genreId) ? sp.genreId[0] : sp.genreId) ?? "";
const languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? "";
const preferredMachineIds = [27, 26, 8, 9];
const machinetypeIds = parseIdList(sp.machinetypeId) ?? preferredMachineIds;
const machinetypeId = machinetypeIds.join(",");
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc";
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const scope = ((Array.isArray(sp.scope) ? sp.scope[0] : sp.scope) ?? "title") as
| "title"
| "title_aliases"
| "title_aliases_origins";
const [initial, genres, langs, machines, facets] = await Promise.all([
searchEntries({
page,
pageSize: 20,
sort,
q,
scope,
genreId: genreId ? Number(genreId) : undefined,
languageId: languageId || undefined,
machinetypeId: machinetypeIds,
}),
listGenres(),
listLanguages(),
listMachinetypes(),
getEntryFacets({
q,
sort,
scope,
genreId: genreId ? Number(genreId) : undefined,
languageId: languageId || undefined,
machinetypeId: machinetypeIds,
}),
]);
return (
<EntriesExplorer
initial={initial}
initialGenres={genres}
initialLanguages={langs}
initialMachines={machines}
initialFacets={facets}
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
/>
);
}

View 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>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Genre = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Genre>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Genre> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/genres?${params.toString()}`);
}
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Genres" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Genres</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="d-grid">
<button className="btn btn-primary">Search</button>
</div>
</form>
</div>
</div>
</div>
<div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((g) => (
<tr key={g.id}>
<td><span className="badge text-bg-light">#{g.id}</span></td>
<td>
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,16 @@
import GenreDetailClient from "./GenreDetail";
import { entriesByGenre } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Genre" };
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const initial = await entriesByGenre(numericId, page, 20, q || undefined);
return <GenreDetailClient id={numericId} initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,14 @@
import GenresSearch from "./GenresSearch";
import { searchGenres } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Genres" };
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchGenres({ q, page, pageSize: 20 });
return <GenresSearch initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,89 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/server/repo/zxdb";
import EntryLink from "@/app/zxdb/components/EntryLink";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Issue" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const issueId = Number(id);
if (!Number.isFinite(issueId) || issueId <= 0) return notFound();
const issue = await getIssue(issueId);
if (!issue) return notFound();
const ym = [issue.dateYear ?? "", issue.dateMonth ? String(issue.dateMonth).padStart(2, "0") : ""].filter(Boolean).join("/");
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines", href: "/zxdb/magazines" },
{ label: issue.magazine.title, href: `/zxdb/magazines/${issue.magazine.id}` },
{ label: `Issue ${ym || issue.id}` },
]}
/>
<div className="mb-3 d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-secondary btn-sm" href={`/zxdb/magazines/${issue.magazine.id}`}> Back to magazine</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link>
{issue.linkMask && (
<a className="btn btn-outline-secondary btn-sm" href={issue.linkMask} target="_blank" rel="noreferrer">Issue link</a>
)}
{issue.archiveMask && (
<a className="btn btn-outline-secondary btn-sm" href={issue.archiveMask} target="_blank" rel="noreferrer">Archive</a>
)}
</div>
<h1 className="mb-1">{issue.magazine.title}</h1>
<div className="text-secondary mb-3">
Issue: {ym || issue.id}{issue.volume != null ? ` · Vol ${issue.volume}` : ""}{issue.number != null ? ` · No ${issue.number}` : ""}
</div>
{(issue.special || issue.supplement) && (
<div className="mb-3">
{issue.special && <div><strong>Special:</strong> {issue.special}</div>}
{issue.supplement && <div><strong>Supplement:</strong> {issue.supplement}</div>}
</div>
)}
<h2 className="h5 mt-4">References</h2>
{issue.refs.length === 0 ? (
<div className="text-secondary">No references recorded.</div>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>Page</th>
<th style={{ width: 140 }}>Type</th>
<th>Reference</th>
</tr>
</thead>
<tbody>
{issue.refs.map((r) => (
<tr key={r.id}>
<td>{r.page}</td>
<td>{r.typeName}</td>
<td>
{r.entryId ? (
<EntryLink id={r.entryId} title={r.entryTitle ?? undefined} />
) : r.labelId ? (
<Link href={`/zxdb/labels/${r.labelId}`}>{r.labelName ?? r.labelId}</Link>
) : (
<span className="text-secondary"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<Label>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Label> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/labels?${params.toString()}`);
}
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Labels" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Labels</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="d-grid">
<button className="btn btn-primary">Search</button>
</div>
</form>
</div>
</div>
</div>
<div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 100 }}>ID</th>
<th>Name</th>
<th style={{ width: 120 }}>Type</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td>#{l.id}</td>
<td>
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
</td>
<td>
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
"use client";
import Link from "next/link";
import EntryLink from "../../components/EntryLink";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
type Label = {
id: number;
name: string;
labeltypeId: string | null;
labeltypeName: string | null;
countryId: string | null;
countryName: string | null;
country2Id: string | null;
country2Name: string | null;
linkWikipedia: string | null;
linkSite: string | null;
permissions: {
website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null };
text: string | null;
}[];
licenses: {
id: number;
name: string;
type: { id: string; name: string | null };
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
};
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
export default function LabelDetailClient({ id, initial, initialTab, initialQ }: { id: number; initial: Payload; initialTab?: "authored" | "published"; initialQ?: string }) {
// Keep only interactive UI state (tab). Data should come directly from SSR props so it updates on navigation.
const [tab, setTab] = useState<"authored" | "published">(initialTab ?? "authored");
const [q, setQ] = useState(initialQ ?? "");
const router = useRouter();
// Names are now delivered by SSR payload to minimize pop-in.
// Hooks must be called unconditionally
const current = useMemo<Paged<Item> | null>(
() => (tab === "authored" ? initial?.authored : initial?.published) ?? null,
[initial, tab]
);
const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]);
if (!initial || !initial.label) return <div className="alert alert-warning">Not found</div>;
return (
<div>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 className="mb-0">{initial.label.name}</h1>
<div>
<span className="badge text-bg-light">
{initial.label.labeltypeName
? `${initial.label.labeltypeName} (${initial.label.labeltypeId ?? "?"})`
: (initial.label.labeltypeId ?? "?")}
</span>
</div>
</div>
{(initial.label.countryId || initial.label.linkWikipedia || initial.label.linkSite) && (
<div className="mt-2 d-flex gap-3 flex-wrap align-items-center">
{initial.label.countryId && (
<span className="text-secondary small">
Country: <strong>{initial.label.countryName || initial.label.countryId}</strong>
{initial.label.country2Id && (
<> / <strong>{initial.label.country2Name || initial.label.country2Id}</strong></>
)}
</span>
)}
{initial.label.linkWikipedia && (
<a href={initial.label.linkWikipedia} target="_blank" rel="noreferrer" className="btn btn-sm btn-outline-secondary py-0">Wikipedia</a>
)}
{initial.label.linkSite && (
<a href={initial.label.linkSite} target="_blank" rel="noreferrer" className="btn btn-sm btn-outline-secondary py-0">Website</a>
)}
</div>
)}
<div className="row g-4 mt-1">
<div className="col-lg-6">
<h5>Permissions</h5>
{initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>}
{initial.label.permissions.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 140 }}>Type</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{initial.label.permissions.map((p, idx) => (
<tr key={`${p.website.id}-${p.type.id}-${idx}`}>
<td>
{p.website.link ? (
<a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
) : (
<span>{p.website.name}</span>
)}
</td>
<td>{p.type.name ?? p.type.id}</td>
<td>{p.text ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="col-lg-6">
<h5>Licenses</h5>
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
{initial.label.licenses.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th style={{ width: 140 }}>Type</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{initial.label.licenses.map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td>{l.type.name ?? l.type.id}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{l.linkWikipedia && (
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
)}
{l.linkSite && (
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
)}
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<ul className="nav nav-tabs mt-3">
<li className="nav-item">
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
</li>
<li className="nav-item">
<button className={`nav-link ${tab === "published" ? "active" : ""}`} onClick={() => setTab("published")}>Published</button>
</li>
</ul>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder={`Search within ${tab}`} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
{current.items.map((it) => (
<tr key={it.id}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="d-flex align-items-center gap-2 mt-2">
<span>Page {current ? current.page : 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-sm btn-outline-secondary ${current && current.page <= 1 ? "disabled" : ""}`}
aria-disabled={current ? current.page <= 1 : true}
href={`/zxdb/labels/${id}?${(() => { const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", String(Math.max(1, (current ? current.page : 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${current && current.page >= totalPages ? "disabled" : ""}`}
aria-disabled={current ? current.page >= totalPages : true}
href={`/zxdb/labels/${id}?${(() => { const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (current ? current.page : 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import LabelDetailClient from "./LabelDetail";
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Label" };
// Depends on searchParams (?page=, ?tab=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const tab = (Array.isArray(sp.tab) ? sp.tab[0] : sp.tab) as "authored" | "published" | undefined;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const [label, authored, published] = await Promise.all([
getLabelById(numericId),
getLabelAuthoredEntries(numericId, { page, pageSize: 20, q: q || undefined }),
getLabelPublishedEntries(numericId, { page, pageSize: 20, q: q || undefined }),
]);
// Let the client component handle the "not found" simple state
return <LabelDetailClient id={numericId} initial={{ label: label, authored: authored, published: published }} initialTab={tab} initialQ={q} />;
}

View File

@@ -0,0 +1,15 @@
import LabelsSearch from "./LabelsSearch";
import { searchLabels } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Labels" };
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchLabels({ q, page, pageSize: 20 });
return <LabelsSearch initial={initial} initialQ={q} />;
}

View 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>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Language = { id: string; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged<Language>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Language> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/languages?${params.toString()}`);
}
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Languages" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Languages</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="d-grid">
<button className="btn btn-primary">Search</button>
</div>
</form>
</div>
</div>
</div>
<div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>Code</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td><span className="badge text-bg-light">{l.id}</span></td>
<td>
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,15 @@
import LanguageDetailClient from "./LanguageDetail";
import { entriesByLanguage } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Language" };
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const initial = await entriesByLanguage(id, page, 20, q || undefined);
return <LanguageDetailClient id={id} initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,15 @@
import LanguagesSearch from "./LanguagesSearch";
import { searchLanguages } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Languages" };
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchLanguages({ q, page, pageSize: 20 });
return <LanguagesSearch initial={initial} initialQ={q} />;
}

View 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>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type MT = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged<MT>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<MT> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
useEffect(() => {
if (initial) setData(initial);
}, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => {
setQ(initialQ ?? "");
}, [initialQ]);
function submit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", "1");
router.push(`/zxdb/machinetypes?${params.toString()}`);
}
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Machine Types" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Machine Types</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="d-grid">
<button className="btn btn-primary">Search</button>
</div>
</form>
</div>
</div>
</div>
<div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id}>
<td><span className="badge text-bg-light">#{m.id}</span></td>
<td>
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,15 @@
import MachineTypeDetailClient from "./MachineTypeDetail";
import { entriesByMachinetype } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Machine Type" };
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const initial = await entriesByMachinetype(numericId, page, 20, q || undefined);
return <MachineTypeDetailClient id={numericId} initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,14 @@
import MachineTypesSearch from "./MachineTypesSearch";
import { searchMachinetypes } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Machine Types" };
export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await searchMachinetypes({ q, page, pageSize: 20 });
return <MachineTypesSearch initial={initial} initialQ={q} />;
}

View File

@@ -0,0 +1,94 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMagazine } from "@/server/repo/zxdb";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazine" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const magazineId = Number(id);
if (!Number.isFinite(magazineId) || magazineId <= 0) return notFound();
const mag = await getMagazine(magazineId);
if (!mag) return notFound();
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines", href: "/zxdb/magazines" },
{ label: mag.title },
]}
/>
<h1 className="mb-1">{mag.title}</h1>
<div className="text-secondary mb-3">Language: {mag.languageId}</div>
<div className="mb-3 d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines"> Back to list</Link>
{mag.linkSite && (
<a className="btn btn-outline-secondary btn-sm" href={mag.linkSite} target="_blank" rel="noreferrer">
Official site
</a>
)}
</div>
<h2 className="h5 mt-4">Issues</h2>
{mag.issues.length === 0 ? (
<div className="text-secondary">No issues found.</div>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th style={{ width: 200 }}>Issue</th>
<th style={{ width: 100 }}>Volume</th>
<th style={{ width: 100 }}>Number</th>
<th>Special</th>
<th>Supplement</th>
<th style={{ width: 100 }}>Links</th>
</tr>
</thead>
<tbody>
{mag.issues.map((i) => (
<tr key={i.id}>
<td>
<Link href={`/zxdb/issues/${i.id}`} className="link-underline link-underline-opacity-0">
{i.dateYear ?? ""}
{i.dateMonth ? `/${String(i.dateMonth).padStart(2, "0")}` : ""}
{" "}
<span className="text-secondary">(open issue)</span>
</Link>
</td>
<td>{i.volume ?? ""}</td>
<td>{i.number ?? ""}</td>
<td>{i.special ?? ""}</td>
<td>{i.supplement ?? ""}</td>
<td>
<div className="d-flex gap-2">
{i.linkMask && (
<a className="btn btn-outline-secondary btn-sm" href={i.linkMask} target="_blank" rel="noreferrer" title="Link">
<span className="bi bi-link-45deg" aria-hidden />
<span className="visually-hidden">Link</span>
</a>
)}
{i.archiveMask && (
<a className="btn btn-outline-secondary btn-sm" href={i.archiveMask} target="_blank" rel="noreferrer" title="Archive">
<span className="bi bi-archive" aria-hidden />
<span className="visually-hidden">Archive</span>
</a>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,117 @@
import Link from "next/link";
import { listMagazines } from "@/server/repo/zxdb";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazines" };
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const data = await listMagazines({ q, page, pageSize: 20 });
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Magazines</h1>
<div className="text-secondary">{data.total.toLocaleString()} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" action="/zxdb/magazines" method="get">
<div>
<label className="form-label small text-secondary">Search</label>
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
</div>
<div className="d-grid">
<button className="btn btn-primary" type="submit">Search</button>
</div>
</form>
</div>
</div>
</div>
<div className="col-lg-9">
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th>Title</th>
<th style={{ width: 140 }}>Language</th>
<th style={{ width: 120 }}>Issues</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id}>
<td>
<Link href={`/zxdb/magazines/${m.id}`}>{m.title}</Link>
</td>
<td>{m.languageId}</td>
<td>
<span className="badge bg-secondary rounded-pill" title="Issues">
{m.issueCount}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} />
</div>
);
}
function Pagination({ page, pageSize, total, q }: { page: number; pageSize: number; total: number; q: string }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const makeHref = (p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/magazines?${params.toString()}`;
};
return (
<nav className="mt-3" aria-label="Pagination">
<ul className="pagination">
<li className={`page-item ${page <= 1 ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.max(1, page - 1))}>
Previous
</Link>
</li>
<li className="page-item disabled">
<span className="page-link">Page {page} of {totalPages}</span>
</li>
<li className={`page-item ${page >= totalPages ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.min(totalPages, page + 1))}>
Next
</Link>
</li>
</ul>
</nav>
);
}

173
src/app/zxdb/page.tsx Normal file
View File

@@ -0,0 +1,173 @@
import Link from "next/link";
import TapeIdentifier from "./TapeIdentifier";
export const metadata = {
title: "ZXDB Explorer",
};
export const revalidate = 3600;
export default async function Page() {
return (
<div className="d-flex flex-column gap-4">
<section
className="rounded-4 p-4 p-lg-5 shadow-sm"
style={{
background: "linear-gradient(135deg, rgba(13,110,253,0.08), rgba(25,135,84,0.08))",
}}
>
<div className="row align-items-center g-4">
<div className="col-lg-7">
<div className="d-flex align-items-center gap-2 mb-3">
<span className="badge text-bg-dark">ZXDB</span>
<span className="badge text-bg-secondary">Explorer</span>
</div>
<h1 className="display-6 mb-3">ZXDB Explorer</h1>
<p className="lead text-secondary mb-4">
Trace Spectrum-era software across entries, releases, magazines, and labels with deep links and fast filters.
</p>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/entries">Browse entries</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/releases">Latest releases</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">Magazine issues</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">People & labels</Link>
</div>
</div>
<div className="col-lg-5">
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title mb-3">Jump straight in</h5>
<form className="d-flex flex-column gap-2" method="get" action="/zxdb/entries">
<div>
<label className="form-label small text-secondary">Search entries</label>
<input className="form-control" name="q" placeholder="Try: manic, doom, renegade..." />
</div>
<div>
<label className="form-label small text-secondary">Scope</label>
<select className="form-select" name="scope" defaultValue="title">
<option value="title">Titles</option>
<option value="title_aliases">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select>
</div>
<button className="btn btn-primary">Search</button>
</form>
</div>
</div>
</div>
</div>
</section>
<section className="row g-3">
<div className="col-lg-8">
<TapeIdentifier />
</div>
<div className="col-lg-4 d-flex align-items-center">
<p className="text-secondary small mb-0">
Drop a <code>.tap</code>, <code>.tzx</code>, or other tape file to identify it against 32,000+ ZXDB entries.
The file stays in your browser &mdash; only its hash is sent.
</p>
</div>
</section>
<section>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h2 className="h4 mb-0">Start exploring</h2>
<span className="text-secondary small">Pick a path to dive deeper</span>
</div>
<div className="row g-3">
<div className="col-sm-6 col-lg-3">
<Link href="/zxdb/entries" className="text-decoration-none">
<div className="card h-100 shadow-sm">
<div className="card-body">
<div className="d-flex align-items-center gap-3">
<span className="bi bi-collection" style={{ fontSize: 28 }} aria-hidden />
<div>
<h5 className="card-title mb-1">Entries</h5>
<div className="card-text text-secondary">Search + filter titles</div>
</div>
</div>
</div>
</div>
</Link>
</div>
<div className="col-sm-6 col-lg-3">
<Link href="/zxdb/releases" className="text-decoration-none">
<div className="card h-100 shadow-sm">
<div className="card-body">
<div className="d-flex align-items-center gap-3">
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} aria-hidden />
<div>
<h5 className="card-title mb-1">Releases</h5>
<div className="card-text text-secondary">Downloads + media</div>
</div>
</div>
</div>
</div>
</Link>
</div>
<div className="col-sm-6 col-lg-3">
<Link href="/zxdb/magazines" className="text-decoration-none">
<div className="card h-100 shadow-sm">
<div className="card-body">
<div className="d-flex align-items-center gap-3">
<span className="bi bi-journal-text" style={{ fontSize: 28 }} aria-hidden />
<div>
<h5 className="card-title mb-1">Magazines</h5>
<div className="card-text text-secondary">Issues + references</div>
</div>
</div>
</div>
</div>
</Link>
</div>
<div className="col-sm-6 col-lg-3">
<Link href="/zxdb/labels" className="text-decoration-none">
<div className="card h-100 shadow-sm">
<div className="card-body">
<div className="d-flex align-items-center gap-3">
<span className="bi bi-people" style={{ fontSize: 28 }} aria-hidden />
<div>
<h5 className="card-title mb-1">Labels</h5>
<div className="card-text text-secondary">People + publishers</div>
</div>
</div>
</div>
</div>
</Link>
</div>
</div>
</section>
<section className="row g-3">
<div className="col-lg-7">
<div className="card h-100 shadow-sm">
<div className="card-body">
<h3 className="h5">Explore by category</h3>
<p className="text-secondary mb-3">Jump to curated lists and filter results from there.</p>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link>
</div>
</div>
</div>
</div>
<div className="col-lg-5">
<div className="card h-100 shadow-sm">
<div className="card-body">
<h3 className="h5">How to use this</h3>
<ol className="mb-0 text-secondary small">
<li>Search by title or aliases in Entries.</li>
<li>Open a release to see downloads, scraps, and places.</li>
<li>Use magazines to find original reviews and references.</li>
<li>Follow labels to discover related work.</li>
</ol>
</div>
</div>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,472 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar";
import MultiSelectChips from "@/components/explorer/MultiSelectChips";
const preferredMachineIds = [27, 26, 8, 9];
function parseMachineIds(value?: string) {
if (!value) return preferredMachineIds.slice();
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : preferredMachineIds.slice();
}
type Item = {
entryId: number;
releaseSeq: number;
entryTitle: string;
year: number | null;
magrefCount: number;
};
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
export default function ReleasesExplorer({
initial,
initialUrlState,
initialUrlHasParams,
initialLists,
}: {
initial?: Paged<Item>;
initialUrlState?: {
q: string;
page: number;
year: string;
sort: "year_desc" | "year_asc" | "title" | "entry_id_desc";
dLanguageId?: string;
dMachinetypeId?: string; // keep as string for URL/state consistency
filetypeId?: string;
schemetypeId?: string;
sourcetypeId?: string;
casetypeId?: string;
isDemo?: string; // "1" or "true"
};
initialUrlHasParams?: boolean;
initialLists?: {
languages: { id: string; name: string }[];
machinetypes: { id: number; name: string }[];
filetypes: { id: number; name: string }[];
schemetypes: { id: string; name: string }[];
sourcetypes: { id: string; name: string }[];
casetypes: { id: string; name: string }[];
};
}) {
const router = useRouter();
const pathname = usePathname();
const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? "");
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
const [year, setYear] = useState<string>(initialUrlState?.year ?? "");
const [sort, setSort] = useState<"year_desc" | "year_asc" | "title" | "entry_id_desc">(initialUrlState?.sort ?? "year_desc");
// Download-based filters and their option lists
const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? "");
const [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.dMachinetypeId));
const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? "");
const [schemetypeId, setSchemetypeId] = useState<string>(initialUrlState?.schemetypeId ?? "");
const [sourcetypeId, setSourcetypeId] = useState<string>(initialUrlState?.sourcetypeId ?? "");
const [casetypeId, setCasetypeId] = useState<string>(initialUrlState?.casetypeId ?? "");
const [isDemo, setIsDemo] = useState<boolean>(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true")));
const [langs, setLangs] = useState<{ id: string; name: string }[]>(initialLists?.languages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []);
const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []);
const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []);
const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []);
const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []);
const initialLoad = useRef(true);
const preferredMachineNames = useMemo(() => {
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`);
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
}, [machines]);
const orderedMachines = useMemo(() => {
const seen = new Set(preferredMachineIds);
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[];
const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest];
}, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
const updateUrl = useCallback((nextPage = page) => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(nextPage));
if (year) params.set("year", year);
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname);
}, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, page, pathname, router, schemetypeId, sort, sourcetypeId, year]);
const fetchData = useCallback(async (query: string, p: number) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("page", String(p));
params.set("pageSize", String(pageSize));
if (year) params.set("year", String(Number(year)));
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1");
const res = await fetch(`/api/zxdb/releases/search?${params.toString()}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json: Paged<Item> = await res.json();
setData(json);
} catch (e) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}, [casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, pageSize, schemetypeId, sort, sourcetypeId, year]);
useEffect(() => {
if (initial) {
setData(initial);
setPage(initial.page);
}
}, [initial]);
const initialState = useMemo(() => ({
q: initialUrlState?.q ?? "",
year: initialUrlState?.year ?? "",
sort: initialUrlState?.sort ?? "year_desc",
dLanguageId: initialUrlState?.dLanguageId ?? "",
dMachinetypeId: initialUrlState?.dMachinetypeId ?? "",
filetypeId: initialUrlState?.filetypeId ?? "",
schemetypeId: initialUrlState?.schemetypeId ?? "",
sourcetypeId: initialUrlState?.sourcetypeId ?? "",
casetypeId: initialUrlState?.casetypeId ?? "",
isDemo: initialUrlState?.isDemo,
}), [initialUrlState]);
useEffect(() => {
const initialPage = initial?.page ?? 1;
if (
initial &&
page === initialPage &&
initialState.q === appliedQ &&
initialState.year === (year ?? "") &&
sort === initialState.sort &&
initialState.dLanguageId === dLanguageId &&
parseMachineIds(initialState.dMachinetypeId).join(",") === dMachinetypeIds.join(",") &&
initialState.filetypeId === filetypeId &&
initialState.schemetypeId === schemetypeId &&
initialState.sourcetypeId === sourcetypeId &&
initialState.casetypeId === casetypeId &&
(!!initialState.isDemo === isDemo)
) {
if (initialLoad.current) {
initialLoad.current = false;
return;
}
updateUrl(page);
return;
}
if (initialLoad.current) {
initialLoad.current = false;
if (initial && !initialUrlHasParams) return;
}
updateUrl(page);
fetchData(appliedQ, page);
}, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, fetchData, filetypeId, initial, initialState, initialUrlHasParams, isDemo, page, schemetypeId, sort, sourcetypeId, updateUrl, year]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setAppliedQ(q);
setPage(1);
}
// Load filter option lists on mount
useEffect(() => {
async function loadLists() {
if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return;
try {
const [l, m, ft, sc, so, ca] = await Promise.all([
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/filetypes", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/schemetypes", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/sourcetypes", { cache: "force-cache" }).then((r) => r.json()),
fetch("/api/zxdb/casetypes", { cache: "force-cache" }).then((r) => r.json()),
]);
setLangs(l.items ?? []);
setMachines(m.items ?? []);
setFiletypes(ft.items ?? []);
setSchemes(sc.items ?? []);
setSources(so.items ?? []);
setCases(ca.items ?? []);
} catch {
// ignore
}
}
loadLists();
}, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]);
const prevHref = useMemo(() => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1)));
if (year) params.set("year", year);
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1");
return `/zxdb/releases?${params.toString()}`;
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
const nextHref = useMemo(() => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1)));
if (year) params.set("year", year);
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1");
return `/zxdb/releases?${params.toString()}`;
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Releases" },
]}
/>
<ExplorerLayout
title="Releases"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
sidebar={(
<FilterSidebar>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div>
<label className="form-label small text-secondary">Search title</label>
<input
type="text"
className="form-control"
placeholder="Filter by entry title..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="d-grid">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div>
<div>
<label className="form-label small text-secondary">Year</label>
<input
type="number"
className="form-control"
placeholder="Any"
value={year}
onChange={(e) => { setYear(e.target.value); setPage(1); }}
/>
</div>
<div>
<label className="form-label small text-secondary">DL Language</label>
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{langs.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">DL Machine</label>
<MultiSelectChips
options={machineOptions}
selected={dMachinetypeIds}
onToggle={(id) => {
setDMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
const order = machineOptions.map((item) => item.id);
return order.filter((value) => next.has(value));
});
setPage(1);
}}
/>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">File type</label>
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
<option value="">All file types</option>
{filetypes.map((ft) => (
<option key={ft.id} value={ft.id}>{ft.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Scheme</label>
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
<option value="">All schemes</option>
{schemes.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Source</label>
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
<option value="">All sources</option>
{sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Case</label>
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
<option value="">All cases</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="form-check">
<input id="demoCheck" className="form-check-input" type="checkbox" checked={isDemo} onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }} />
<label className="form-check-label" htmlFor="demoCheck">Demo only</label>
</div>
<div>
<label className="form-label small text-secondary">Sort</label>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
<option value="year_desc">Newest</option>
<option value="year_asc">Oldest</option>
<option value="title">Title</option>
<option value="entry_id_desc">Entry ID</option>
</select>
</div>
{loading && <div className="text-secondary small">Loading...</div>}
</form>
</FilterSidebar>
)}
>
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>Entry ID</th>
<th>Title</th>
<th style={{ width: 140 }}>Release #</th>
<th style={{ width: 110 }}>Places</th>
<th style={{ width: 100 }}>Year</th>
</tr>
</thead>
<tbody>
{data.items.map((it) => (
<tr key={`${it.entryId}-${it.releaseSeq}`}>
<td>
<EntryLink id={it.entryId} />
</td>
<td>
<div className="d-flex flex-column gap-1">
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`} className="link-underline link-underline-opacity-0">
{it.entryTitle || `Entry #${it.entryId}`}
</Link>
</div>
</td>
<td>
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
#{it.releaseSeq}
</Link>
</td>
<td>
{it.magrefCount > 0 ? (
<span className="badge text-bg-secondary">{it.magrefCount}</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>{it.year ?? <span className="text-secondary">-</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4">
<span>Page {data?.page ?? 1} / {totalPages}</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={prevHref}
onClick={(e) => {
if (!data || data.page <= 1) return;
e.preventDefault();
setPage((p) => Math.max(1, p - 1));
}}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={nextHref}
onClick={(e) => {
if (!data || data.page >= totalPages) return;
e.preventDefault();
setPage((p) => Math.min(totalPages, p + 1));
}}
>
Next
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,611 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type ReleaseDetailData = {
entry: {
id: number;
title: string;
issueId: number | null;
};
entryReleases: Array<{
releaseSeq: number;
year: number | null;
}>;
release: {
entryId: number;
releaseSeq: number;
year: number | null;
month: number | null;
day: number | null;
currency: { id: string | null; name: string | null; symbol: string | null; prefix: number | null };
prices: {
release: number | null;
budget: number | null;
microdrive: number | null;
disk: number | null;
cartridge: number | null;
};
book: { isbn: string | null; pages: number | null };
};
downloads: Array<{
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
isDemo: boolean;
type: { id: number; name: string };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
scheme: { id: string | null; name: string | null };
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
localLink?: string | null;
}>;
scraps: Array<{
id: number;
link: string | null;
size: number | null;
comments: string | null;
rationale: string;
isDemo: boolean;
type: { id: number; name: string };
language: { id: string | null; name: string | null };
machinetype: { id: number | null; name: string | null };
scheme: { id: string | null; name: string | null };
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
localLink?: string | null;
}>;
files: Array<{
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
type: { id: number; name: string };
}>;
magazineRefs: Array<{
id: number;
issueId: number;
magazineId: number | null;
magazineName: string | null;
referencetypeId: number;
referencetypeName: string | null;
page: number;
isOriginal: number;
scoreGroup: string;
issue: {
dateYear: number | null;
dateMonth: number | null;
dateDay: number | null;
volume: number | null;
number: number | null;
special: string | null;
supplement: string | null;
};
}>;
};
function formatIssue(issue: ReleaseDetailData["magazineRefs"][number]["issue"]) {
const parts: string[] = [];
if (issue.volume != null) parts.push(`v.${issue.volume}`);
if (issue.number != null) parts.push(`#${issue.number}`);
if (issue.dateYear != null) {
let date = `${issue.dateYear}`;
if (issue.dateMonth != null) {
const mm = String(issue.dateMonth).padStart(2, "0");
date += `/${mm}`;
if (issue.dateDay != null) {
const dd = String(issue.dateDay).padStart(2, "0");
date += `/${dd}`;
}
}
parts.push(date);
}
if (issue.special) parts.push(`special "${issue.special}"`);
if (issue.supplement) parts.push(`supplement "${issue.supplement}"`);
return parts.join(" ");
}
function formatCurrency(value: number | null, currency: ReleaseDetailData["release"]["currency"]) {
if (value == null) return "-";
if (currency.symbol) {
return currency.prefix ? `${currency.symbol}${value}` : `${value}${currency.symbol}`;
}
if (currency.name) return `${value} ${currency.name}`;
return String(value);
}
type MagazineGroup = {
magazineId: number | null;
magazineName: string | null;
items: ReleaseDetailData["magazineRefs"];
};
type IssueGroup = {
issueId: number;
issue: ReleaseDetailData["magazineRefs"][number]["issue"];
items: ReleaseDetailData["magazineRefs"];
};
function groupMagazineRefs(refs: ReleaseDetailData["magazineRefs"]) {
const groups: MagazineGroup[] = [];
const lookup = new Map<string, MagazineGroup>();
for (const ref of refs) {
const key = ref.magazineId != null ? `mag:${ref.magazineId}` : "mag:unknown";
let group = lookup.get(key);
if (!group) {
group = { magazineId: ref.magazineId, magazineName: ref.magazineName, items: [] };
lookup.set(key, group);
groups.push(group);
}
group.items.push(ref);
}
return groups;
}
function groupIssueRefs(refs: ReleaseDetailData["magazineRefs"]) {
const groups: IssueGroup[] = [];
const lookup = new Map<number, IssueGroup>();
for (const ref of refs) {
const key = ref.issueId;
let group = lookup.get(key);
if (!group) {
group = { issueId: ref.issueId, issue: ref.issue, items: [] };
lookup.set(key, group);
groups.push(group);
}
group.items.push(ref);
}
return groups;
}
export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) {
const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null);
const groupedDownloads = useMemo(() => {
if (!data?.downloads) return [];
const groups = new Map<string, ReleaseDetailData["downloads"]>();
for (const d of data.downloads) {
const type = d.type.name;
const arr = groups.get(type) ?? [];
arr.push(d);
groups.set(type, arr);
}
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [data?.downloads]);
const groupedScraps = useMemo(() => {
if (!data?.scraps) return [];
const groups = new Map<string, ReleaseDetailData["scraps"]>();
for (const s of data.scraps) {
const type = s.type.name;
const arr = groups.get(type) ?? [];
arr.push(s);
groups.set(type, arr);
}
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [data?.scraps]);
if (!data) return <div className="alert alert-warning">Not found</div>;
const magazineGroups = groupMagazineRefs(data.magazineRefs);
const otherReleases = data.entryReleases.filter((r) => r.releaseSeq !== data.release.releaseSeq);
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Releases", href: "/zxdb/releases" },
{ label: data.entry.title, href: `/zxdb/entries/${data.entry.id}` },
{ label: `Release #${data.release.releaseSeq}` },
]}
/>
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">Release #{data.release.releaseSeq}</h1>
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/entries/${data.entry.id}`}>
{data.entry.title}
</Link>
</div>
<div className="row g-3 mt-2">
<div className="col-lg-4">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Release Summary</h5>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<tbody>
<tr>
<th style={{ width: 160 }}>Entry</th>
<td>
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
</td>
</tr>
<tr>
<th>Release Sequence</th>
<td>#{data.release.releaseSeq}</td>
</tr>
<tr>
<th>Release Date</th>
<td>
{data.release.year != null ? (
<span>
{data.release.year}
{data.release.month != null ? `/${String(data.release.month).padStart(2, "0")}` : ""}
{data.release.day != null ? `/${String(data.release.day).padStart(2, "0")}` : ""}
</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<th>Currency</th>
<td>
{data.release.currency.id ? (
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<th>Price</th>
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
</tr>
<tr>
<th>Budget Price</th>
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
</tr>
<tr>
<th>Microdrive Price</th>
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
</tr>
<tr>
<th>Disk Price</th>
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
</tr>
<tr>
<th>Cartridge Price</th>
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
</tr>
<tr>
<th>Book ISBN</th>
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
</tr>
<tr>
<th>Book Pages</th>
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<h5 className="card-title">Other Releases</h5>
{otherReleases.length === 0 && <div className="text-secondary">No other releases</div>}
{otherReleases.length > 0 && (
<div className="d-flex flex-wrap gap-2">
{otherReleases.map((r) => (
<Link
key={r.releaseSeq}
className="badge text-bg-light text-decoration-none"
href={`/zxdb/releases/${data.entry.id}/${r.releaseSeq}`}
>
#{r.releaseSeq}{r.year != null ? ` · ${r.year}` : ""}
</Link>
))}
</div>
)}
</div>
</div>
</div>
<div className="col-lg-8">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Places (Magazines)</h5>
{magazineGroups.length === 0 && <div className="text-secondary">No magazine references</div>}
{magazineGroups.length > 0 && (
<div className="d-flex flex-column gap-3">
{magazineGroups.map((group) => (
<div key={group.magazineId ?? "unknown"}>
<div className="d-flex align-items-center justify-content-between">
<div className="fw-semibold">
{group.magazineId != null ? (
<Link href={`/zxdb/magazines/${group.magazineId}`}>
{group.magazineName ?? `Magazine #${group.magazineId}`}
</Link>
) : (
<span className="text-secondary">Unknown magazine</span>
)}
</div>
<div className="text-secondary small">{group.items.length} reference{group.items.length === 1 ? "" : "s"}</div>
</div>
{groupIssueRefs(group.items).map((issueGroup) => (
<div key={issueGroup.issueId} className="mt-2">
<div className="d-flex align-items-center justify-content-between">
<div>
<Link href={`/zxdb/issues/${issueGroup.issueId}`}>Issue #{issueGroup.issueId}</Link>
<div className="text-secondary small">{formatIssue(issueGroup.issue) || "-"}</div>
</div>
<div className="text-secondary small">
{issueGroup.items.length} reference{issueGroup.items.length === 1 ? "" : "s"}
</div>
</div>
<div className="table-responsive mt-2">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>Page</th>
<th style={{ width: 120 }}>Type</th>
<th style={{ width: 100 }}>Original</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{issueGroup.items.map((m) => (
<tr key={m.id}>
<td>{m.page}</td>
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
<td>{m.isOriginal ? "Yes" : "No"}</td>
<td>{m.scoreGroup || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Downloads</h5>
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{groupedDownloads.map(([type, items]) => (
<div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Link</th>
<th style={{ width: 100 }} className="text-end">Size</th>
<th style={{ width: 180 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{items?.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
const fileName = d.link.split("/").pop() || "file";
const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
return (
<tr key={d.id}>
<td>
<div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : (
<span className="text-break small">{d.link}</span>
)}
{canPreview && (
<button
className="btn btn-xs btn-outline-info py-0 px-1"
style={{ fontSize: "0.6rem" }}
onClick={() => setViewer({ url: d.localLink!, title: fileName })}
>
Preview
</button>
)}
</div>
{d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
<td>
<div className="d-flex gap-1 flex-wrap">
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{d.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
) : null}
{d.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
) : null}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
</div>
</td>
<td className="small">{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Scraps / Media</h5>
{groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>}
{groupedScraps.map(([type, items]) => (
<div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Link</th>
<th style={{ width: 100 }} className="text-end">Size</th>
<th>Flags</th>
<th>Details</th>
<th>Rationale</th>
</tr>
</thead>
<tbody>
{items?.map((s) => {
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
const fileName = s.link?.split("/").pop() || "file";
const canPreview = s.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
return (
<tr key={s.id}>
<td>
<div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{s.link ? (
isHttp ? (
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
) : (
<span className="text-break small">{s.link}</span>
)
) : (
<span className="text-secondary">-</span>
)}
{canPreview && (
<button
className="btn btn-xs btn-outline-info py-0 px-1"
style={{ fontSize: "0.6rem" }}
onClick={() => setViewer({ url: s.localLink!, title: fileName })}
>
Preview
</button>
)}
</div>
{s.localLink && (
<a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</td>
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
<td>
<div className="d-flex gap-1 flex-wrap">
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
{s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{s.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link>
) : null}
{s.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link>
) : null}
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
</div>
</td>
<td className="small">{s.rationale}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Issue Files</h5>
{data.files.length === 0 && <div className="text-secondary">No files linked</div>}
{data.files.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 240 }}>MD5</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.files.map((f) => {
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
return (
<tr key={f.id}>
<td><span className="badge text-bg-secondary">{f.type.name}</span></td>
<td>
{isHttp ? (
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
) : (
<span>{f.link}</span>
)}
</td>
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
<td><code>{f.md5 ?? "-"}</code></td>
<td>{f.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
<div className="d-flex align-items-center gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/releases/${data.entry.id}/${data.release.releaseSeq}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb/releases">Back to Releases</Link>
</div>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import ReleaseDetailClient from "./ReleaseDetail";
import { getReleaseDetail } from "@/server/repo/zxdb";
export const metadata = {
title: "ZXDB Release",
};
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }) {
const { entryId, releaseSeq } = await params;
const entryIdNum = Number(entryId);
const releaseSeqNum = Number(releaseSeq);
const data = await getReleaseDetail(entryIdNum, releaseSeqNum);
return <ReleaseDetailClient data={data} />;
}

View File

@@ -0,0 +1,68 @@
import ReleasesExplorer from "./ReleasesExplorer";
import { listCasetypes, listFiletypes, listLanguages, listMachinetypes, listSchemetypes, listSourcetypes, searchReleases } from "@/server/repo/zxdb";
export const metadata = {
title: "ZXDB Releases",
};
export const dynamic = "force-dynamic";
function parseIdList(value: string | string[] | undefined) {
if (!value) return undefined;
const raw = Array.isArray(value) ? value.join(",") : value;
const ids = raw
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const hasParams = Object.values(sp).some((value) => value !== undefined);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const yearStr = (Array.isArray(sp.year) ? sp.year[0] : sp.year) ?? "";
const year = yearStr ? Number(yearStr) : undefined;
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "year_desc") as "year_desc" | "year_asc" | "title" | "entry_id_desc";
const dLanguageId = (Array.isArray(sp.dLanguageId) ? sp.dLanguageId[0] : sp.dLanguageId) ?? "";
const preferredMachineIds = [27, 26, 8, 9];
const dMachinetypeIds = parseIdList(sp.dMachinetypeId) ?? preferredMachineIds;
const dMachinetypeIdStr = dMachinetypeIds.join(",");
const filetypeIdStr = (Array.isArray(sp.filetypeId) ? sp.filetypeId[0] : sp.filetypeId) ?? "";
const filetypeId = filetypeIdStr ? Number(filetypeIdStr) : undefined;
const schemetypeId = (Array.isArray(sp.schemetypeId) ? sp.schemetypeId[0] : sp.schemetypeId) ?? "";
const sourcetypeId = (Array.isArray(sp.sourcetypeId) ? sp.sourcetypeId[0] : sp.sourcetypeId) ?? "";
const casetypeId = (Array.isArray(sp.casetypeId) ? sp.casetypeId[0] : sp.casetypeId) ?? "";
const isDemoStr = (Array.isArray(sp.isDemo) ? sp.isDemo[0] : sp.isDemo) ?? "";
const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined;
const [initial, langs, machines, filetypes, schemes, sources, cases] = await Promise.all([
searchReleases({ page, pageSize: 20, q, year, sort, dLanguageId: dLanguageId || undefined, dMachinetypeId: dMachinetypeIds, filetypeId, schemetypeId: schemetypeId || undefined, sourcetypeId: sourcetypeId || undefined, casetypeId: casetypeId || undefined, isDemo }),
listLanguages(),
listMachinetypes(),
listFiletypes(),
listSchemetypes(),
listSourcetypes(),
listCasetypes(),
]);
// Ensure the object passed to a Client Component is a plain JSON value
const initialPlain = JSON.parse(JSON.stringify(initial));
return (
<ReleasesExplorer
initial={initialPlain}
initialLists={{
languages: JSON.parse(JSON.stringify(langs)),
machinetypes: JSON.parse(JSON.stringify(machines)),
filetypes: JSON.parse(JSON.stringify(filetypes)),
schemetypes: JSON.parse(JSON.stringify(schemes)),
sourcetypes: JSON.parse(JSON.stringify(sources)),
casetypes: JSON.parse(JSON.stringify(cases)),
}}
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
initialUrlHasParams={hasParams}
/>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { Modal, Button, Spinner } from "react-bootstrap";
type FileViewerProps = {
url: string;
title: string;
onClose: () => void;
};
export default function FileViewer({ url, title, onClose }: FileViewerProps) {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isText = title.toLowerCase().endsWith(".txt") || title.toLowerCase().endsWith(".nfo");
const isImage = title.toLowerCase().match(/\.(png|jpg|jpeg|gif)$/);
const isPdf = title.toLowerCase().endsWith(".pdf");
const viewUrl = url.includes("?") ? `${url}&view=1` : `${url}?view=1`;
useState(() => {
if (isText) {
fetch(viewUrl)
.then((res) => {
if (!res.ok) throw new Error("Failed to load file");
return res.text();
})
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
} else {
setLoading(false);
}
});
return (
<Modal show size="xl" onHide={onClose} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0 bg-dark text-light" style={{ minHeight: "300px" }}>
{loading && (
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: "300px" }}>
<Spinner animation="border" variant="light" />
</div>
)}
{error && (
<div className="p-4 text-center">
<p className="text-danger">{error}</p>
</div>
)}
{!loading && !error && (
<>
{isText && (
<pre className="p-3 m-0" style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: "0.9rem", color: "#ccc" }}>
{content}
</pre>
)}
{isImage && (
<div className="text-center p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={viewUrl} alt={title} className="img-fluid" style={{ maxHeight: "80vh" }} />
</div>
)}
{isPdf && (
<iframe src={viewUrl} style={{ width: "100%", height: "80vh", border: "none" }} title={title} />
)}
{!isText && !isImage && !isPdf && (
<div className="p-4 text-center">
<p>Preview not available for this file type.</p>
<a href={url} className="btn btn-primary">Download File</a>
</div>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Close</Button>
<a href={url} className="btn btn-success" download>Download</a>
</Modal.Footer>
</Modal>
);
}

View File

@@ -1,8 +1,7 @@
"use client";
import Link from "next/link";
import * as Icon from "react-bootstrap-icons";
import { Navbar, Nav, Container, Dropdown } from "react-bootstrap";
import { Navbar, Nav, Container } from "react-bootstrap";
import ThemeDropdown from "@/components/ThemeDropdown";
export default function NavbarClient() {
@@ -15,6 +14,7 @@ export default function NavbarClient() {
<Nav className="me-auto mb-2 mb-lg-0">
<Link className="nav-link" href="/">Home</Link>
<Link className="nav-link" href="/registers">Registers</Link>
<Link className="nav-link" href="/zxdb">ZXDB</Link>
</Nav>
<ThemeDropdown />
@@ -22,4 +22,4 @@ export default function NavbarClient() {
</Container>
</Navbar>
);
}
}

View File

@@ -0,0 +1,39 @@
import { ReactNode } from "react";
import FilterChips from "./FilterChips";
type ExplorerLayoutProps = {
title: string;
subtitle?: string;
chips?: string[];
onClearChips?: () => void;
sidebar: ReactNode;
children: ReactNode;
};
export default function ExplorerLayout({
title,
subtitle,
chips = [],
onClearChips,
sidebar,
children,
}: ExplorerLayoutProps) {
return (
<div>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">{title}</h1>
{subtitle ? <div className="text-secondary">{subtitle}</div> : null}
</div>
{chips.length > 0 ? (
<FilterChips chips={chips} onClear={onClearChips} />
) : null}
</div>
<div className="row g-3">
<div className="col-lg-3">{sidebar}</div>
<div className="col-lg-9">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
type FilterChipsProps = {
chips: string[];
onClear?: () => void;
clearLabel?: string;
};
export default function FilterChips({ chips, onClear, clearLabel = "Clear filters" }: FilterChipsProps) {
return (
<div className="d-flex flex-wrap gap-2 align-items-center">
{chips.map((chip) => (
<span key={chip} className="badge text-bg-light">{chip}</span>
))}
{onClear ? (
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClear}>
{clearLabel}
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { ReactNode } from "react";
type FilterSidebarProps = {
children: ReactNode;
};
export default function FilterSidebar({ children }: FilterSidebarProps) {
return (
<div className="card shadow-sm">
<div className="card-body">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
type ChipOption<T extends number | string> = {
id: T;
label: string;
};
type MultiSelectChipsProps<T extends number | string> = {
options: ChipOption<T>[];
selected: T[];
onToggle: (id: T) => void;
size?: "sm" | "md";
};
export default function MultiSelectChips<T extends number | string>({
options,
selected,
onToggle,
size = "sm",
}: MultiSelectChipsProps<T>) {
const btnSize = size === "sm" ? "btn-sm" : "";
return (
<div className="d-flex flex-wrap gap-2">
{options.map((option) => {
const active = selected.includes(option.id);
return (
<button
key={String(option.id)}
type="button"
className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => onToggle(option.id)}
>
{option.label}
</button>
);
})}
</div>
);
}

54
src/env.ts Normal file
View File

@@ -0,0 +1,54 @@
import { z } from "zod";
// Server-side environment schema (t3.gg style)
const serverSchema = z.object({
// Full MySQL connection URL, e.g. mysql://user:pass@host:3306/zxdb
ZXDB_URL: z
.string()
.url()
.refine((s) => s.startsWith("mysql://"), {
message: "ZXDB_URL must be a valid mysql:// URL",
}),
// Optional file prefixes for ZXDB and WOS
ZXDB_FILE_PREFIX: z.string().optional(),
WOS_FILE_PREFIX: z.string().optional(),
// Local file paths for mirroring
ZXDB_LOCAL_FILEPATH: z.string().optional(),
WOS_LOCAL_FILEPATH: z.string().optional(),
// OIDC Configuration
OIDC_PROVIDER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
// Redis cache and SMTP mail URLs
CACHE_URL: z.string().url().optional(),
MAIL_URL: z.string().url().optional(),
// System hostname for permalinks (mandatory)
HOSTNAME: z.string().min(1),
PROTO: z.string().startsWith("http"),
});
function formatErrors(errors: z.ZodFormattedError<Map<string, string>, string>) {
return Object.entries(errors)
.map(([name, value]) => {
if (value && "_errors" in value) {
const errs = (value as z.ZodFormattedError<string>)._errors;
return `${name}: ${errs.join(", ")}`;
}
return `${name}: invalid`;
})
.join("\n");
}
const parsed = serverSchema.safeParse(process.env);
if (!parsed.success) {
// Fail fast with helpful output in server context
console.error("❌ Invalid environment variables:\n" + formatErrors(parsed.error.format()));
throw new Error("Invalid environment variables");
}
export const env = parsed.data;

15
src/server/db.ts Normal file
View File

@@ -0,0 +1,15 @@
import mysql from "mysql2/promise";
import { drizzle } from "drizzle-orm/mysql2";
import { env } from "@/env";
// Create a singleton connection pool for the ZXDB database
const pool = mysql.createPool({
uri: env.ZXDB_URL,
connectionLimit: 10,
// Larger queries may be needed for ZXDB
maxPreparedStatements: 256,
});
export const db = drizzle(pool);
export type Db = typeof db;

2707
src/server/repo/zxdb.ts Normal file

File diff suppressed because it is too large Load Diff

661
src/server/schema/zxdb.ts Normal file
View File

@@ -0,0 +1,661 @@
import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext, bigint, timestamp } from "drizzle-orm/mysql-core";
// Minimal subset needed for browsing/searching
export const entries = mysqlTable("entries", {
id: int("id").notNull().primaryKey(),
title: varchar("title", { length: 250 }).notNull(),
isXrated: tinyint("is_xrated").notNull(),
machinetypeId: tinyint("machinetype_id"),
maxPlayers: tinyint("max_players").notNull().default(1),
// DB allows NULLs on many of these
languageId: char("language_id", { length: 2 }),
genretypeId: tinyint("genretype_id"),
genretypeSpotId: tinyint("spot_genretype_id"),
availabletypeId: char("availabletype_id", { length: 1 }),
withoutLoadScreen: tinyint("without_load_screen").notNull(),
withoutInlay: tinyint("without_inlay").notNull(),
issueId: int("issue_id"),
});
// Helper table created by ZXDB_help_search.sql
export const searchByTitles = mysqlTable("search_by_titles", {
entryTitle: varchar("entry_title", { length: 250 }).notNull(),
entryId: int("entry_id").notNull(),
});
export type Entry = typeof entries.$inferSelect;
// ZXDB labels (people/companies/teams)
export const labels = mysqlTable("labels", {
id: int("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
countryId: char("country_id", { length: 2 }),
country2Id: char("country2_id", { length: 2 }),
fromId: int("from_id"),
ownerId: int("owner_id"),
wasRenamed: tinyint("was_renamed").notNull().default(0),
deceased: varchar("deceased", { length: 200 }),
linkWikipedia: varchar("link_wikipedia", { length: 200 }),
linkSite: varchar("link_site", { length: 200 }),
labeltypeId: char("labeltype_id", { length: 1 }),
});
// Helper table for names search
export const searchByNames = mysqlTable("search_by_names", {
labelName: varchar("label_name", { length: 100 }).notNull(),
labelId: int("label_id").notNull(),
});
// Helper: entries by authors
export const searchByAuthors = mysqlTable("search_by_authors", {
labelId: int("label_id").notNull(),
entryId: int("entry_id").notNull(),
});
// Helper: entries by publishers
export const searchByPublishers = mysqlTable("search_by_publishers", {
labelId: int("label_id").notNull(),
entryId: int("entry_id").notNull(),
});
// Relations tables
export const authors = mysqlTable("authors", {
entryId: int("entry_id").notNull(),
labelId: int("label_id").notNull(),
teamId: int("team_id"),
// Present in schema; sequence of the author for a given entry
authorSeq: smallint("author_seq").notNull().default(1),
});
export const publishers = mysqlTable("publishers", {
entryId: int("entry_id").notNull(),
labelId: int("label_id").notNull(),
});
// Lookups
export const languages = mysqlTable("languages", {
id: char("id", { length: 2 }).notNull().primaryKey(),
// Column name in DB is `text`; map to `name` property for app ergonomics
name: varchar("text", { length: 100 }).notNull(),
});
export const machinetypes = mysqlTable("machinetypes", {
id: tinyint("id").notNull().primaryKey(),
// Column name in DB is `text`
name: varchar("text", { length: 50 }).notNull(),
});
export const genretypes = mysqlTable("genretypes", {
id: tinyint("id").notNull().primaryKey(),
// Column name in DB is `text`
name: varchar("text", { length: 50 }).notNull(),
});
// Additional lookups
export const availabletypes = mysqlTable("availabletypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
// DB column `text`
name: varchar("text", { length: 50 }).notNull(),
});
export const currencies = mysqlTable("currencies", {
id: char("id", { length: 3 }).notNull().primaryKey(),
name: varchar("name", { length: 50 }).notNull(),
symbol: varchar("symbol", { length: 20 }),
// Stored as tinyint(1) 0/1
prefix: tinyint("prefix").notNull(),
});
// ----- Files and Filetypes (for downloads/assets) -----
export const filetypes = mysqlTable("filetypes", {
id: tinyint("id").notNull().primaryKey(),
// Column name in DB is `text`
name: varchar("text", { length: 50 }).notNull(),
});
export const files = mysqlTable("files", {
id: int("id").notNull().primaryKey(),
labelId: int("label_id"),
issueId: int("issue_id"),
toolId: int("tool_id"),
fileLink: varchar("file_link", { length: 250 }).notNull(),
fileDate: varchar("file_date", { length: 50 }),
fileSize: int("file_size"),
fileMd5: varchar("file_md5", { length: 32 }),
filetypeId: tinyint("filetype_id").notNull(),
comments: varchar("comments", { length: 250 }),
});
export const schemetypes = mysqlTable("schemetypes", {
id: char("id", { length: 2 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const sourcetypes = mysqlTable("sourcetypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const casetypes = mysqlTable("casetypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const roletypes = mysqlTable("roletypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const hosts = mysqlTable("hosts", {
id: tinyint("id").notNull().primaryKey(),
title: varchar("title", { length: 150 }).notNull(),
link: varchar("link", { length: 150 }).notNull(),
admin: varchar("admin", { length: 150 }).notNull(),
magazineId: smallint("magazine_id"),
});
// ---- Magazines and Issues (subset used by the app) ----
export const magazines = mysqlTable("magazines", {
id: smallint("id").notNull().primaryKey(),
// ZXDB column is `name`
name: varchar("name", { length: 100 }).notNull(),
countryId: char("country_id", { length: 2 }).notNull(),
languageId: char("language_id", { length: 2 }).notNull(),
linkSite: varchar("link_site", { length: 200 }),
magtypeId: char("magtype_id", { length: 1 }).notNull(),
topicId: int("topic_id"),
linkMask: varchar("link_mask", { length: 250 }),
archiveMask: varchar("archive_mask", { length: 250 }),
translationMask: varchar("translation_mask", { length: 250 }),
});
export const issues = mysqlTable("issues", {
id: int("id").notNull().primaryKey(),
magazineId: smallint("magazine_id").notNull(),
dateYear: smallint("date_year"),
dateMonth: smallint("date_month"),
dateDay: smallint("date_day"),
volume: smallint("volume"),
number: smallint("number"),
special: varchar("special", { length: 100 }),
supplement: varchar("supplement", { length: 100 }),
linkMask: varchar("link_mask", { length: 250 }),
archiveMask: varchar("archive_mask", { length: 250 }),
});
// ---- Aliases (alternative titles per entry/release/language)
export const aliases = mysqlTable("aliases", {
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq").notNull().default(0),
languageId: char("language_id", { length: 2 }).notNull(),
title: varchar("title", { length: 250 }).notNull(),
});
// `releases` are identified by (entry_id, release_seq)
export const releases = mysqlTable("releases", {
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq").notNull(),
releaseYear: smallint("release_year"),
releaseMonth: smallint("release_month"),
releaseDay: smallint("release_day"),
currencyId: char("currency_id", { length: 3 }),
releasePrice: decimal("release_price", { precision: 9, scale: 2 }),
budgetPrice: decimal("budget_price", { precision: 9, scale: 2 }),
microdrivePrice: decimal("microdrive_price", { precision: 9, scale: 2 }),
diskPrice: decimal("disk_price", { precision: 9, scale: 2 }),
cartridgePrice: decimal("cartridge_price", { precision: 9, scale: 2 }),
bookIsbn: varchar("book_isbn", { length: 50 }),
bookPages: smallint("book_pages"),
});
// Downloads are linked to a release via (entry_id, release_seq)
export const downloads = mysqlTable("downloads", {
id: int("id").notNull().primaryKey(),
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq").notNull().default(0),
fileLink: varchar("file_link", { length: 250 }).notNull(),
fileDate: varchar("file_date", { length: 50 }),
fileSize: int("file_size"),
fileMd5: varchar("file_md5", { length: 32 }),
filetypeId: tinyint("filetype_id").notNull(),
scrBorder: tinyint("scr_border").notNull().default(7),
languageId: char("language_id", { length: 2 }),
isDemo: tinyint("is_demo").notNull(),
schemetypeId: char("schemetype_id", { length: 2 }),
machinetypeId: tinyint("machinetype_id"),
fileCode: varchar("file_code", { length: 50 }),
fileBarcode: varchar("file_barcode", { length: 50 }),
fileDl: varchar("file_dl", { length: 150 }),
casetypeId: char("casetype_id", { length: 1 }),
sourcetypeId: char("sourcetype_id", { length: 1 }),
releaseYear: smallint("release_year"),
comments: varchar("comments", { length: 250 }),
});
// ---- Web references (external links tied to entries)
export const webrefs = mysqlTable("webrefs", {
entryId: int("entry_id").notNull(),
link: varchar("link", { length: 200 }).notNull(),
websiteId: tinyint("website_id").notNull(),
languageId: char("language_id", { length: 2 }).notNull(),
});
export const websites = mysqlTable("websites", {
id: tinyint("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
comments: varchar("comments", { length: 100 }),
link: varchar("link", { length: 100 }),
linkMask: varchar("link_mask", { length: 100 }),
});
// Roles relation (composite PK in DB)
export const roles = mysqlTable("roles", {
entryId: int("entry_id").notNull(),
labelId: int("label_id").notNull(),
roletypeId: char("roletype_id", { length: 1 }).notNull(),
});
// ---- Additional ZXDB schema coverage (lookups and content) ----
export const articletypes = mysqlTable("articletypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const articles = mysqlTable("articles", {
labelId: int("label_id").notNull(),
link: varchar("link", { length: 200 }).notNull(),
articletypeId: char("articletype_id", { length: 1 }).notNull(),
title: varchar("title", { length: 200 }),
languageId: char("language_id", { length: 2 }).notNull(),
writer: varchar("writer", { length: 200 }),
dateYear: smallint("date_year"),
});
export const categories = mysqlTable("categories", {
id: smallint("id").notNull().primaryKey(),
// DB column `text`
name: varchar("text", { length: 50 }).notNull(),
});
export const contenttypes = mysqlTable("contenttypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const contents = mysqlTable("contents", {
// ZXDB contents table does not have its own `id`; natural key is (issue_id, page_from, page_to, label_id, entry_id)
entryId: int("entry_id").notNull(),
labelId: int("label_id"),
issueId: int("issue_id").notNull(),
contenttypeId: char("contenttype_id", { length: 1 }).notNull(),
pageFrom: smallint("page_from"),
pageTo: smallint("page_to"),
title: varchar("title", { length: 200 }),
dateYear: smallint("date_year"),
rating: tinyint("rating"),
comments: varchar("comments", { length: 250 }),
});
export const extensions = mysqlTable("extensions", {
ext: varchar("ext", { length: 15 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const features = mysqlTable("features", {
id: int("id").notNull().primaryKey(),
name: varchar("name", { length: 150 }).notNull(),
version: tinyint("version").notNull().default(0),
labelId: int("label_id"),
label2Id: int("label2_id"),
});
export const tooltypes = mysqlTable("tooltypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const tools = mysqlTable("tools", {
id: int("id").notNull().primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
languageId: char("language_id", { length: 2 }),
tooltypeId: char("tooltype_id", { length: 1 }),
link: varchar("link", { length: 200 }),
});
// ---- Magazine references (per-issue references to entries/labels/topics) ----
export const referencetypes = mysqlTable("referencetypes", {
id: tinyint("id").notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const magrefs = mysqlTable("magrefs", {
id: int("id").notNull().primaryKey(),
referencetypeId: tinyint("referencetype_id").notNull(),
entryId: int("entry_id"),
labelId: int("label_id"),
topicId: int("topic_id"),
issueId: int("issue_id").notNull(),
page: smallint("page").notNull().default(0),
isOriginal: tinyint("is_original").notNull().default(0),
scoreGroup: varchar("score_group", { length: 100 }).notNull().default(""),
reviewId: int("review_id"),
awardId: tinyint("award_id"),
});
// ---- Extended ZXDB schema coverage (structure-only) ----
export const booktypeins = mysqlTable("booktypeins", {
entryId: int("entry_id").notNull(),
bookId: int("book_id").notNull(),
installment: smallint("installment").notNull().default(0),
volume: smallint("volume").notNull().default(0),
page: smallint("page").notNull().default(0),
isOriginal: tinyint("is_original").notNull().default(0),
});
export const countries = mysqlTable("countries", {
id: char("id", { length: 2 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const labeltypes = mysqlTable("labeltypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const licenses = mysqlTable("licenses", {
id: int("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
licensetypeId: char("licensetype_id", { length: 1 }).notNull(),
linkWikipedia: varchar("link_wikipedia", { length: 200 }),
linkSite: varchar("link_site", { length: 200 }),
comments: varchar("comments", { length: 500 }),
});
export const licensetypes = mysqlTable("licensetypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const licensors = mysqlTable("licensors", {
licenseId: int("license_id").notNull(),
labelId: int("label_id").notNull(),
});
export const magreffeats = mysqlTable("magreffeats", {
magrefId: int("magref_id").notNull(),
featureId: int("feature_id").notNull(),
});
export const magreflinks = mysqlTable("magreflinks", {
magrefId: int("magref_id").notNull(),
link: varchar("link", { length: 250 }).notNull(),
hostId: tinyint("host_id").notNull(),
});
export const magtypes = mysqlTable("magtypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const members = mysqlTable("members", {
tagId: int("tag_id").notNull(),
entryId: int("entry_id").notNull(),
categoryId: smallint("category_id").notNull().default(1),
memberSeq: smallint("member_seq"),
});
export const notes = mysqlTable("notes", {
id: int("id").notNull().primaryKey(),
entryId: int("entry_id"),
labelId: int("label_id"),
notetypeId: char("notetype_id", { length: 1 }).notNull(),
text: mediumtext("text").notNull(),
});
export const notetypes = mysqlTable("notetypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 100 }).notNull(),
});
export const nvgs = mysqlTable("nvgs", {
id: int("id").notNull().primaryKey(),
title: varchar("title", { length: 250 }).notNull(),
entryId: int("entry_id"),
fileLink: varchar("file_link", { length: 250 }),
fileDate: varchar("file_date", { length: 50 }),
fileSize: int("file_size"),
filetypeId: tinyint("filetype_id"),
isDemo: tinyint("is_demo").notNull().default(0),
machinetypeId: tinyint("machinetype_id"),
comments: varchar("comments", { length: 500 }),
url: varchar("url", { length: 100 }),
});
export const origintypes = mysqlTable("origintypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const permissions = mysqlTable("permissions", {
websiteId: tinyint("website_id").notNull(),
labelId: int("label_id").notNull(),
permissiontypeId: char("permissiontype_id", { length: 1 }).notNull(),
text: varchar("text", { length: 300 }),
});
export const permissiontypes = mysqlTable("permissiontypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const platforms = mysqlTable("platforms", {
id: tinyint("id").notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const ports = mysqlTable("ports", {
id: int("id").notNull().primaryKey(),
title: varchar("title", { length: 250 }),
entryId: int("entry_id").notNull(),
platformId: tinyint("platform_id").notNull(),
isOfficial: tinyint("is_official").notNull(),
linkSystem: varchar("link_system", { length: 200 }),
});
export const prefixes = mysqlTable("prefixes", {
text: varchar("text", { length: 10 }).notNull().primaryKey(),
});
export const prefixexempts = mysqlTable("prefixexempts", {
text: varchar("text", { length: 50 }).notNull().primaryKey(),
});
export const relatedlicenses = mysqlTable("relatedlicenses", {
entryId: int("entry_id").notNull(),
licenseId: int("license_id").notNull(),
isOfficial: tinyint("is_official").notNull(),
});
export const relations = mysqlTable("relations", {
entryId: int("entry_id").notNull(),
originalId: int("original_id").notNull(),
relationtypeId: char("relationtype_id", { length: 1 }).notNull(),
});
export const relationtypes = mysqlTable("relationtypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
reciprocal: varchar("reciprocal", { length: 50 }).notNull(),
comments: varchar("comments", { length: 250 }),
});
export const remakes = mysqlTable("remakes", {
id: int("id").notNull().primaryKey(),
entryId: int("entry_id").notNull(),
title: varchar("title", { length: 250 }).notNull(),
fileLink: varchar("file_link", { length: 250 }).notNull(),
fileDate: varchar("file_date", { length: 50 }),
fileSize: int("file_size"),
authors: varchar("authors", { length: 250 }),
platforms: varchar("platforms", { length: 200 }),
remakeYears: varchar("remake_years", { length: 100 }),
remakeStatus: varchar("remake_status", { length: 1000 }),
});
export const scores = mysqlTable("scores", {
websiteId: tinyint("website_id").notNull(),
entryId: int("entry_id").notNull(),
score: decimal("score", { precision: 5, scale: 2 }).notNull(),
votes: int("votes").notNull(),
});
export const scraps = mysqlTable("scraps", {
id: int("id").notNull().primaryKey(),
entryId: int("entry_id"),
releaseSeq: smallint("release_seq"),
fileLink: varchar("file_link", { length: 250 }),
fileDate: varchar("file_date", { length: 50 }),
fileSize: int("file_size"),
filetypeId: tinyint("filetype_id").notNull(),
languageId: char("language_id", { length: 2 }),
isDemo: tinyint("is_demo").notNull(),
schemetypeId: char("schemetype_id", { length: 2 }),
machinetypeId: tinyint("machinetype_id"),
fileCode: varchar("file_code", { length: 50 }),
fileBarcode: varchar("file_barcode", { length: 50 }),
fileDl: varchar("file_dl", { length: 150 }),
casetypeId: char("casetype_id", { length: 1 }),
sourcetypeId: char("sourcetype_id", { length: 1 }),
releaseYear: smallint("release_year"),
comments: varchar("comments", { length: 250 }),
rationale: varchar("rationale", { length: 100 }).notNull(),
});
export const searchByAliases = mysqlTable("search_by_aliases", {
entryId: int("entry_id").notNull(),
title: varchar("title", { length: 250 }).notNull(),
libraryTitle: varchar("library_title", { length: 300 }).notNull(),
});
export const searchByIssues = mysqlTable("search_by_issues", {
issueId: int("issue_id").notNull().primaryKey(),
name: varchar("name", { length: 300 }).notNull(),
});
export const searchByMagazines = mysqlTable("search_by_magazines", {
magazineId: smallint("magazine_id").notNull(),
labelId: int("label_id").notNull(),
});
export const searchByMagrefs = mysqlTable("search_by_magrefs", {
entryId: int("entry_id").notNull(),
magrefId: int("magref_id").notNull(),
});
export const searchByOrigins = mysqlTable("search_by_origins", {
entryId: int("entry_id").notNull().primaryKey(),
libraryTitle: varchar("library_title", { length: 300 }).notNull(),
origintypeId: char("origintype_id", { length: 1 }).notNull(),
containerId: int("container_id"),
issueId: int("issue_id"),
dateYear: smallint("date_year"),
dateMonth: smallint("date_month"),
dateDay: smallint("date_day"),
publication: varchar("publication", { length: 300 }),
});
export const spexAuthors = mysqlTable("spex_authors", {
id: int("id").notNull().primaryKey(),
entryId: int("entry_id").notNull(),
name: varchar("name", { length: 150 }).notNull(),
labelId: int("label_id"),
});
export const spexEntries = mysqlTable("spex_entries", {
id: int("id").notNull().primaryKey(),
title: varchar("title", { length: 150 }).notNull(),
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq"),
pub1LabelId: int("pub1_label_id"),
pub2LabelId: int("pub2_label_id"),
pub3LabelId: int("pub3_label_id"),
genretypeId: tinyint("genretype_id"),
orgprice: decimal("orgprice", { precision: 5, scale: 2 }).notNull(),
repub2price: decimal("repub2price", { precision: 5, scale: 2 }).notNull(),
repub3price: decimal("repub3price", { precision: 5, scale: 2 }).notNull(),
diskprice: decimal("diskprice", { precision: 5, scale: 2 }).notNull(),
fgtkey: varchar("fgtkey", { length: 150 }).notNull(),
});
export const tags = mysqlTable("tags", {
id: int("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
link: varchar("link", { length: 200 }),
comments: varchar("comments", { length: 1500 }),
tagtypeId: char("tagtype_id", { length: 1 }).notNull(),
toolId: int("tool_id"),
});
export const tagtypes = mysqlTable("tagtypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const topics = mysqlTable("topics", {
id: int("id").notNull().primaryKey(),
topictypeId: char("topictype_id", { length: 1 }).notNull(),
labelId: int("label_id"),
magazineId: smallint("magazine_id"),
name: varchar("name", { length: 150 }).notNull(),
comments: varchar("comments", { length: 150 }),
});
export const topictypes = mysqlTable("topictypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const zxsrAwards = mysqlTable("zxsr_awards", {
id: tinyint("id").notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
magazineId: smallint("magazine_id").notNull(),
});
export const zxsrCaptions = mysqlTable("zxsr_captions", {
magrefId: int("magref_id").notNull(),
captionSeq: smallint("caption_seq").notNull(),
text: text("text").notNull(),
isBanner: tinyint("is_banner").notNull(),
});
export const zxsrReviews = mysqlTable("zxsr_reviews", {
id: int("id").notNull().primaryKey(),
introText: longtext("intro_text"),
reviewText: longtext("review_text"),
reviewRating: varchar("review_rating", { length: 2000 }),
});
export const zxsrScores = mysqlTable("zxsr_scores", {
id: int("id").notNull().primaryKey(),
magrefId: int("magref_id").notNull(),
scoreSeq: tinyint("score_seq"),
category: varchar("category", { length: 100 }).notNull(),
isOverall: tinyint("is_overall").notNull().default(0),
score: varchar("score", { length: 100 }),
comments: text("comments"),
});
// ---- Derived tables (managed by update scripts, not part of ZXDB upstream) ----
// Stores MD5, CRC32 and size of the inner tape file extracted from download zips.
// Populated by bin/update-software-hashes.mjs; survives DB wipes via JSON snapshot.
export const softwareHashes = mysqlTable("software_hashes", {
downloadId: int("download_id").notNull().primaryKey(),
md5: varchar("md5", { length: 32 }).notNull(),
crc32: varchar("crc32", { length: 8 }).notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
innerPath: varchar("inner_path", { length: 500 }).notNull(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

164
src/utils/md5.ts Normal file
View File

@@ -0,0 +1,164 @@
// Pure-JS MD5 for browser use (Web Crypto doesn't support MD5).
// Standard RFC 1321 implementation, typed for TypeScript.
function md5cycle(x: number[], k: number[]) {
let a = x[0], b = x[1], c = x[2], d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function add32(a: number, b: number) {
return (a + b) & 0xffffffff;
}
function md5blk(s: Uint8Array, offset: number): number[] {
const md5blks: number[] = [];
for (let i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s[offset + i] +
(s[offset + i + 1] << 8) +
(s[offset + i + 2] << 16) +
(s[offset + i + 3] << 24);
}
return md5blks;
}
const hex = "0123456789abcdef".split("");
function rhex(n: number) {
let s = "";
for (let j = 0; j < 4; j++) {
s += hex[(n >> (j * 8 + 4)) & 0x0f] + hex[(n >> (j * 8)) & 0x0f];
}
return s;
}
function md5raw(bytes: Uint8Array): string {
const n = bytes.length;
const state = [1732584193, -271733879, -1732584194, 271733878];
let i: number;
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk(bytes, i - 64));
}
// Tail: copy remaining bytes into a padded buffer
const tail = new Uint8Array(64);
const remaining = n - (i - 64);
for (let j = 0; j < remaining; j++) {
tail[j] = bytes[i - 64 + j];
}
tail[remaining] = 0x80;
// If remaining >= 56 we need an extra block
if (remaining >= 56) {
md5cycle(state, md5blk(tail, 0));
tail.fill(0);
}
// Append bit length as 64-bit little-endian
const bitLen = n * 8;
tail[56] = bitLen & 0xff;
tail[57] = (bitLen >> 8) & 0xff;
tail[58] = (bitLen >> 16) & 0xff;
tail[59] = (bitLen >> 24) & 0xff;
// For files < 512 MB the high 32 bits are 0; safe for tape images
md5cycle(state, md5blk(tail, 0));
return rhex(state[0]) + rhex(state[1]) + rhex(state[2]) + rhex(state[3]);
}
// Reads a File as ArrayBuffer and returns its MD5 hex digest.
export async function computeMd5(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
return md5raw(new Uint8Array(buffer));
}

View File

@@ -10,13 +10,13 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
// Footnote multiline state
let inFootnote = false;
let footnoteBaseIndent = 0;
let footnoteTarget: 'global' | 'access' | null = null;
// let footnoteTarget: 'global' | 'access' | null = null;
let currentFootnote: Note | null = null;
const endFootnoteIfActive = () => {
inFootnote = false;
footnoteBaseIndent = 0;
footnoteTarget = null;
// footnoteTarget = null;
currentFootnote = null;
};
@@ -89,10 +89,10 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
const note: Note = { ref: noteMatch[1], text: noteMatch[2] };
if (currentAccess) {
accessData.notes.push(note);
footnoteTarget = 'access';
// footnoteTarget = 'access';
} else {
reg.notes.push(note);
footnoteTarget = 'global';
// footnoteTarget = 'global';
}
currentFootnote = note;
inFootnote = true;