49 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
56 changed files with 269135 additions and 1152 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ next-env.d.ts
.pnpm .pnpm
.pnpm-store .pnpm-store
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
bin/sync-downloads.mjs

View File

@@ -134,6 +134,7 @@ Comment what the code does, not what the agent has done. The documentation's pur
- Database connection via `mysql2` pool wrapped by Drizzle (`src/server/db.ts`). - 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. - 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`). - 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`. - 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. - API routes under `/api/zxdb/*` validate inputs with Zod and run on Node runtime.
@@ -146,14 +147,29 @@ Comment what the code does, not what the agent has done. The documentation's pur
- git branching: - git branching:
- Do not create new branches - Do not create new branches
- git commits: - git commits:
- Create COMMIT_EDITMSG file, await any user edits, then commit using that - Create or update COMMIT_EDITMSG file if commits pending, await any user
commit note, and then delete the COMMIT_EDITMSG file. Remember to keep edits, or additional instructions. Once told, commit all the changes
the first line as the subject <50char using that commit note, and then delete the COMMIT_EDITMSG file.
Remember to keep the first line as the subject <50char
- git commit messages: - git commit messages:
- Use imperative mood (e.g., "Add feature X", "Fix bug Y"). - Use imperative mood (e.g., "Add feature X", "Fix bug Y").
- Include relevant issue numbers if applicable. - Include relevant issue numbers if applicable.
- Sign-off commit message as <agent-name>@<hostname> - 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 ### References
- ZXDB setup and API usage: `docs/ZXDB.md` - ZXDB setup and API usage: `docs/ZXDB.md`

1
CLAUDE.md Symbolic link
View File

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

View File

@@ -22,6 +22,9 @@ Project scripts (package.json)
- `dev`: `PORT=4000 next dev --turbopack` - `dev`: `PORT=4000 next dev --turbopack`
- `build`: `next build --turbopack` - `build`: `next build --turbopack`
- `start`: `next start` - `start`: `next start`
- `deploy`: merge current branch into `deploy` and push to `explorer.specnext.dev`
- `deploy:branch`: same as `deploy`, but accepts a deploy branch argument
- `setup:zxdb-local`: configure local submodule excludes for ZXDB SQL files
- `deploy-test`: push to `test.explorer.specnext.dev` - `deploy-test`: push to `test.explorer.specnext.dev`
- `deploy-prod`: push to `explorer.specnext.dev` - `deploy-prod`: push to `explorer.specnext.dev`
@@ -59,6 +62,10 @@ The Registers section works without any database. The ZXDB Explorer requires a M
3) Run the app 3) Run the app
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`. - `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) API (selected endpoints)
- `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1` - `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1`
- `GET /api/zxdb/entries/[id]` - `GET /api/zxdb/entries/[id]`

2
ZXDB

Submodule ZXDB updated: 3784c91bdd...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}"

View File

@@ -1,12 +1,85 @@
#!/bin/bash #!/bin/bash
mysql -uroot -p -hquinn < ZXDB/ZXDB_mysql.sql # Parse connection details from ZXDB_URL in .env
{ 1 ↵ git:feat/zxdb ✗› v22.21.1 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 @OLD_SQL_MODE := @@SESSION.sql_mode;"
echo "SET SESSION sql_mode := REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '');" echo "SET SESSION sql_mode := REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '');"
cat ZXDB/scripts/ZXDB_help_search.sql cat ZXDB/scripts/ZXDB_help_search.sql
echo "SET SESSION sql_mode := @OLD_SQL_MODE;" echo "SET SESSION sql_mode := @OLD_SQL_MODE;"
echo "CREATE ROLE 'zxdb_readonly';" # echo "CREATE ROLE IF NOT EXISTS 'zxdb_readonly';"
echo "GRANT SELECT, SHOW VIEW ON `zxdb`.* TO 'zxdb_readonly';" # echo "GRANT SELECT, SHOW VIEW ON \`zxdb\`.* TO 'zxdb_readonly';"
} | mysql -uroot -p -hquinn zxdb } | mysql --force $MYSQL_ARGS "$DB_NAME"
mysqldump --no-data -hquinn -uroot -p zxdb > ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql # ---- 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

View File

@@ -4,13 +4,14 @@ This document explains how the ZXDB Explorer works in this project, how to set u
## What is ZXDB? ## 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`. 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 ## Prerequisites
- MySQL server with ZXDB data (or at minimum the tables; data is needed to browse). - 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). - Ability to run the helper SQL that builds search tables (required for efficient LIKE searches).
- A readonly MySQL user for the app (recommended). - 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 ## Database setup
@@ -19,7 +20,8 @@ ZXDB ( https://github.com/zxdb/ZXDB )is a communitymaintained database of ZX
2. Create helper search tables (required). 2. Create helper search tables (required).
- Run `https://github.com/zxdb/ZXDB/blob/master/scripts/ZXDB_help_search.sql` on your ZXDB database. - 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`, and `search_by_publishers` tables. - 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). 3. Create a readonly role/user (recommended).
- Create user `zxdb_readonly`. - Create user `zxdb_readonly`.
@@ -37,6 +39,34 @@ Notes:
- The URL must start with `mysql://`. Env is validated at boot by `src/env.ts` (Zod), failing fast if misconfigured. - 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. - 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 ## Running
``` ```
@@ -48,9 +78,14 @@ pnpm dev
## Explorer UI overview ## Explorer UI overview
- `/zxdb` — Search entries by title and filter by genre, language, and machine type; sort and paginate results. - `/zxdb` — Search entries by title and filter by genre, language, and machine type; sort and paginate results.
- `/zxdb/entries/[id]` — Entry details with badges for genre/language/machine, and linked authors/publishers. - `/zxdb/entries` — Entries search with scope toggles (titles/aliases/origins) and facets.
- `/zxdb/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies) and view authored/published entries. - `/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/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. Crosslinking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched.
@@ -67,6 +102,7 @@ All endpoints are under `/api/zxdb` and validate inputs with Zod. Responses are
- `page`, `pageSize` — pagination (default pageSize=20, max=100) - `page`, `pageSize` — pagination (default pageSize=20, max=100)
- `genreId`, `languageId`, `machinetypeId` — optional filters - `genreId`, `languageId`, `machinetypeId` — optional filters
- `sort``title` or `id_desc` - `sort``title` or `id_desc`
- `scope``title`, `title_aliases`, or `title_aliases_origins`
- `facets` — boolean; if truthy, includes facet counts for genres/languages/machines - `facets` — boolean; if truthy, includes facet counts for genres/languages/machines
- Entry detail - Entry detail
@@ -99,12 +135,13 @@ Runtime: API routes declare `export const runtime = "nodejs"` to support `mysql2
## Troubleshooting ## Troubleshooting
- 400 from dynamic API routes: ensure you await `ctx.params` before Zod validation. - 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`. - 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. - 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. - MySQL auth or network errors: verify `ZXDB_URL` and that your user has read permissions.
## Roadmap ## Roadmap
- Facet counts displayed in the `/zxdb` filter UI. - Issue-centric media grouping and richer magazine metadata.
- Breadcrumbs and additional a11y polish. - Additional cross-links for tags, relations, and permissions as UI expands.
- Media assets and download links per release (future). - A11y polish and higher-level navigation enhancements.

View File

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

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 - Getting Started: ./getting-started.md
- Architecture: ./architecture.md - Architecture: ./architecture.md
- Register Explorer: ./registers.md - Register Explorer: ./registers.md
- ZXDB Explorer: ./ZXDB.md
If youre browsing on GitHub, the main README also links to these documents. 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. |

View File

@@ -12,19 +12,22 @@ PROTO=http
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
# Base HTTP locations for CDN sources used by downloads.file_link # Base HTTP locations for CDN sources used by downloads.file_link
# When file_link starts with /zxdb, it will be fetched from ZXDB_FILEPATH # When file_link starts with /zxdb, it will be fetched from ZXDB_REMOTE_FILEPATH
ZXDB_FILEPATH=https://zxdbfiles.com/ ZXDB_REMOTE_FILEPATH=https://zxdbfiles.com/
# When file_link starts with /public, it will be fetched from WOS_FILEPATH # 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 # Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
WOS_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/ 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 cache root where files will be mirrored (without the leading slash) # Local mirror filesystem paths for downloads.
CDN_CACHE=/mnt/files/zxfiles # 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: File prefixes for localized mirroring or rewrite logic # Optional: Path prefixes to strip from database links before local matching.
# ZXDB_FILE_PREFIX= # ZXDB_FILE_PREFIX=/zxdb/sinclair/
# WOS_FILE_PREFIX= # WOS_FILE_PREFIX=/pub/sinclair/
# OIDC Authentication configuration # OIDC Authentication configuration
# OIDC_PROVIDER_URL= # OIDC_PROVIDER_URL=

View File

@@ -7,6 +7,11 @@
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"deploy": "bin/deploy.sh",
"deploy:branch": "bin/deploy.sh",
"setup:zxdb-local": "bin/setup-zxdb-local.sh",
"update:hashes": "node bin/update-software-hashes.mjs",
"export:hashes": "node bin/update-software-hashes.mjs --export-only",
"deploy-prod": "git push --set-upstream explorer.specnext.dev deploy", "deploy-prod": "git push --set-upstream explorer.specnext.dev deploy",
"deploy-test": "git push --set-upstream test.explorer.specnext.dev test" "deploy-test": "git push --set-upstream test.explorer.specnext.dev test"
}, },

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

@@ -9,7 +9,7 @@ const querySchema = z.object({
year: z.coerce.number().int().optional(), year: z.coerce.number().int().optional(),
sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(), sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(),
dLanguageId: z.string().trim().length(2).optional(), dLanguageId: z.string().trim().length(2).optional(),
dMachinetypeId: z.coerce.number().int().positive().optional(), dMachinetypeId: z.string().optional(),
filetypeId: z.coerce.number().int().positive().optional(), filetypeId: z.coerce.number().int().positive().optional(),
schemetypeId: z.string().trim().length(2).optional(), schemetypeId: z.string().trim().length(2).optional(),
sourcetypeId: z.string().trim().length(1).optional(), sourcetypeId: z.string().trim().length(1).optional(),
@@ -17,6 +17,15 @@ const querySchema = z.object({
isDemo: z.coerce.boolean().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) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({ const parsed = querySchema.safeParse({
@@ -39,7 +48,8 @@ export async function GET(req: NextRequest) {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });
} }
const data = await searchReleases(parsed.data); const dMachinetypeId = parseIdList(parsed.data.dMachinetypeId);
const data = await searchReleases({ ...parsed.data, dMachinetypeId });
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });

View File

@@ -12,11 +12,22 @@ const querySchema = z.object({
.trim() .trim()
.length(2, "languageId must be a 2-char code") .length(2, "languageId must be a 2-char code")
.optional(), .optional(),
machinetypeId: z.coerce.number().int().positive().optional(), machinetypeId: z.string().optional(),
year: z.coerce.number().int().optional(),
sort: z.enum(["title", "id_desc"]).optional(), sort: z.enum(["title", "id_desc"]).optional(),
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
facets: z.coerce.boolean().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) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({ const parsed = querySchema.safeParse({
@@ -26,7 +37,9 @@ export async function GET(req: NextRequest) {
genreId: searchParams.get("genreId") ?? undefined, genreId: searchParams.get("genreId") ?? undefined,
languageId: searchParams.get("languageId") ?? undefined, languageId: searchParams.get("languageId") ?? undefined,
machinetypeId: searchParams.get("machinetypeId") ?? undefined, machinetypeId: searchParams.get("machinetypeId") ?? undefined,
year: searchParams.get("year") ?? undefined,
sort: searchParams.get("sort") ?? undefined, sort: searchParams.get("sort") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
facets: searchParams.get("facets") ?? undefined, facets: searchParams.get("facets") ?? undefined,
}); });
if (!parsed.success) { if (!parsed.success) {
@@ -35,9 +48,11 @@ export async function GET(req: NextRequest) {
{ status: 400, headers: { "content-type": "application/json" } } { status: 400, headers: { "content-type": "application/json" } }
); );
} }
const data = await searchEntries(parsed.data); const machinetypeId = parseIdList(parsed.data.machinetypeId);
const searchParamsParsed = { ...parsed.data, machinetypeId };
const data = await searchEntries(searchParamsParsed);
const body = parsed.data.facets const body = parsed.data.facets
? { ...data, facets: await getEntryFacets(parsed.data) } ? { ...data, facets: await getEntryFacets(searchParamsParsed) }
: data; : data;
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },

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

View File

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

View File

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

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

@@ -41,6 +41,7 @@ export default function ZxdbExplorer({
const [genreId, setGenreId] = useState<number | "">(""); const [genreId, setGenreId] = useState<number | "">("");
const [languageId, setLanguageId] = useState<string | "">(""); const [languageId, setLanguageId] = useState<string | "">("");
const [machinetypeId, setMachinetypeId] = useState<number | "">(""); const [machinetypeId, setMachinetypeId] = useState<number | "">("");
const [year, setYear] = useState<string>("");
const [sort, setSort] = useState<"title" | "id_desc">("id_desc"); const [sort, setSort] = useState<"title" | "id_desc">("id_desc");
const pageSize = 20; const pageSize = 20;
@@ -56,6 +57,7 @@ export default function ZxdbExplorer({
if (genreId !== "") params.set("genreId", String(genreId)); if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId)); if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
if (year !== "") params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
const res = await fetch(`/api/zxdb/search?${params.toString()}`); const res = await fetch(`/api/zxdb/search?${params.toString()}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`); if (!res.ok) throw new Error(`Failed: ${res.status}`);
@@ -89,13 +91,14 @@ export default function ZxdbExplorer({
genreId === "" && genreId === "" &&
languageId === "" && languageId === "" &&
machinetypeId === "" && machinetypeId === "" &&
year === "" &&
sort === "id_desc" sort === "id_desc"
) { ) {
return; return;
} }
fetchData(q, page); fetchData(q, page);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeId, sort]); }, [page, genreId, languageId, machinetypeId, year, sort]);
// Load filter lists on mount only if not provided by server // Load filter lists on mount only if not provided by server
useEffect(() => { useEffect(() => {
@@ -161,6 +164,16 @@ export default function ZxdbExplorer({
))} ))}
</select> </select>
</div> </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"> <div className="col-auto">
<select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as "title" | "id_desc")}> <select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as "title" | "id_desc")}>
<option value="title">Sort: Title</option> <option value="title">Sort: Title</option>

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,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

@@ -4,17 +4,27 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import EntryLink from "../components/EntryLink"; import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation"; 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 = { type Item = {
id: number; id: number;
title: string; title: string;
isXrated: number; isXrated: number;
genreId: number | null;
genreName?: string | null;
machinetypeId: number | null; machinetypeId: number | null;
machinetypeName?: string | null; machinetypeName?: string | null;
languageId: string | null; languageId: string | null;
languageName?: string | null; languageName?: string | null;
}; };
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
type Paged<T> = { type Paged<T> = {
items: T[]; items: T[];
page: number; page: number;
@@ -22,30 +32,50 @@ type Paged<T> = {
total: 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({ export default function EntriesExplorer({
initial, initial,
initialGenres, initialGenres,
initialLanguages, initialLanguages,
initialMachines, initialMachines,
initialFacets,
initialUrlState, initialUrlState,
}: { }: {
initial?: Paged<Item>; initial?: Paged<Item>;
initialGenres?: { id: number; name: string }[]; initialGenres?: { id: number; name: string }[];
initialLanguages?: { id: string; name: string }[]; initialLanguages?: { id: string; name: string }[];
initialMachines?: { id: number; name: string }[]; initialMachines?: { id: number; name: string }[];
initialFacets?: EntryFacets | null;
initialUrlState?: { initialUrlState?: {
q: string; q: string;
page: number; page: number;
genreId: string | number | ""; genreId: string | number | "";
languageId: string | ""; languageId: string | "";
machinetypeId: string | number | ""; machinetypeId: string;
sort: "title" | "id_desc"; 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 router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [q, setQ] = useState(initialUrlState?.q ?? ""); const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? "");
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1); const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | null>(initial ?? null); const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
@@ -56,27 +86,58 @@ export default function EntriesExplorer({
initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : "" initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : ""
); );
const [languageId, setLanguageId] = useState<string | "">(initialUrlState?.languageId ?? ""); const [languageId, setLanguageId] = useState<string | "">(initialUrlState?.languageId ?? "");
const [machinetypeId, setMachinetypeId] = useState<number | "">( const [machinetypeIds, setMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.machinetypeId));
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
);
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc"); 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 pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); 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) { function updateUrl(nextPage = page) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(nextPage)); params.set("page", String(nextPage));
if (genreId !== "") params.set("genreId", String(genreId)); if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId)); if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
const qs = params.toString(); const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname); router.replace(qs ? `${pathname}?${qs}` : pathname);
} }
async function fetchData(query: string, p: number) { async function fetchData(query: string, p: number, withFacets: boolean) {
setLoading(true); setLoading(true);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -85,12 +146,17 @@ export default function EntriesExplorer({
params.set("pageSize", String(pageSize)); params.set("pageSize", String(pageSize));
if (genreId !== "") params.set("genreId", String(genreId)); if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId)); if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort); 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()}`); const res = await fetch(`/api/zxdb/search?${params.toString()}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`); if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json: Paged<Item> = await res.json(); const json = await res.json();
setData(json); setData(json);
if (withFacets && json.facets) {
setFacets(json.facets as EntryFacets);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 }); setData({ items: [], page: 1, pageSize, total: 0 });
@@ -114,20 +180,20 @@ export default function EntriesExplorer({
if ( if (
initial && initial &&
page === initialPage && page === initialPage &&
(initialUrlState?.q ?? "") === q && (initialUrlState?.q ?? "") === appliedQ &&
(initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) && (initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) &&
(initialUrlState?.languageId ?? "") === (languageId ?? "") && (initialUrlState?.languageId ?? "") === (languageId ?? "") &&
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) === parseMachineIds(initialUrlState?.machinetypeId).join(",") === machinetypeIds.join(",") &&
(machinetypeId === "" ? "" : Number(machinetypeId)) && sort === (initialUrlState?.sort ?? "id_desc") &&
sort === (initialUrlState?.sort ?? "id_desc") (initialUrlState?.scope ?? "title") === scope
) { ) {
updateUrl(page); updateUrl(page);
return; return;
} }
updateUrl(page); updateUrl(page);
fetchData(q, page); fetchData(appliedQ, page, true);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeId, sort]); }, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]);
// Load filter lists on mount only if not provided by server // Load filter lists on mount only if not provided by server
useEffect(() => { useEffect(() => {
@@ -149,85 +215,159 @@ export default function EntriesExplorer({
function onSubmit(e: React.FormEvent) { function onSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setAppliedQ(q);
setPage(1);
}
function resetFilters() {
setQ("");
setAppliedQ("");
setGenreId("");
setLanguageId("");
setMachinetypeIds(preferredMachineIds.slice());
setSort("id_desc");
setScope("title");
setPage(1); setPage(1);
updateUrl(1);
fetchData(q, 1);
} }
const prevHref = useMemo(() => { const prevHref = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); params.set("page", String(Math.max(1, (data?.page ?? 1) - 1)));
if (genreId !== "") params.set("genreId", String(genreId)); if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId)); if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`; return `/zxdb/entries?${params.toString()}`;
}, [q, data?.page, genreId, languageId, machinetypeId, sort]); }, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
const nextHref = useMemo(() => { const nextHref = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1))); params.set("page", String(Math.max(1, (data?.page ?? 1) + 1)));
if (genreId !== "") params.set("genreId", String(genreId)); if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId)); if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`; return `/zxdb/entries?${params.toString()}`;
}, [q, data?.page, genreId, languageId, machinetypeId, sort]); }, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
return ( return (
<div> <div>
<h1 className="mb-3">Entries</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
<input { label: "Entries" },
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)); setPage(1); }}>
<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); setPage(1); }}>
<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)); setPage(1); }}>
<option value="">Machine</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
<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"> <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 && ( {data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div> <div className="alert alert-warning">No results.</div>
)} )}
@@ -236,18 +376,28 @@ export default function EntriesExplorer({
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style={{width: 80}}>ID</th> <th style={{ width: 80 }}>ID</th>
<th>Title</th> <th>Title</th>
<th style={{width: 160}}>Machine</th> <th style={{ width: 160 }}>Genre</th>
<th style={{width: 120}}>Language</th> <th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((it) => ( {data.items.map((it) => (
<tr key={it.id}> <tr key={it.id}>
<td><EntryLink id={it.id} /></td> <td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td> <td>
<EntryLink id={it.id} title={it.title} /> {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>
<td> <td>
{it.machinetypeId != null ? ( {it.machinetypeId != null ? (
@@ -277,12 +427,10 @@ export default function EntriesExplorer({
</table> </table>
</div> </div>
)} )}
</div> </ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-4">
<span> <span>Page {data?.page ?? 1} / {totalPages}</span>
Page {data?.page ?? 1} / {totalPages}
</span>
<div className="ms-auto d-flex gap-2"> <div className="ms-auto d-flex gap-2">
<Link <Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import EntriesExplorer from "./EntriesExplorer"; import EntriesExplorer from "./EntriesExplorer";
import { listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb"; import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb";
export const metadata = { export const metadata = {
title: "ZXDB Entries", title: "ZXDB Entries",
@@ -7,28 +7,53 @@ export const metadata = {
export const dynamic = "force-dynamic"; 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 }> }) { export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams; const sp = await searchParams;
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); 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 genreId = (Array.isArray(sp.genreId) ? sp.genreId[0] : sp.genreId) ?? "";
const languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? ""; const languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? "";
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? ""; 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 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 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] = await Promise.all([ const [initial, genres, langs, machines, facets] = await Promise.all([
searchEntries({ searchEntries({
page, page,
pageSize: 20, pageSize: 20,
sort, sort,
q, q,
scope,
genreId: genreId ? Number(genreId) : undefined, genreId: genreId ? Number(genreId) : undefined,
languageId: languageId || undefined, languageId: languageId || undefined,
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined, machinetypeId: machinetypeIds,
}), }),
listGenres(), listGenres(),
listLanguages(), listLanguages(),
listMachinetypes(), listMachinetypes(),
getEntryFacets({
q,
sort,
scope,
genreId: genreId ? Number(genreId) : undefined,
languageId: languageId || undefined,
machinetypeId: machinetypeIds,
}),
]); ]);
return ( return (
@@ -37,7 +62,8 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
initialGenres={genres} initialGenres={genres}
initialLanguages={langs} initialLanguages={langs}
initialMachines={machines} initialMachines={machines}
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }} initialFacets={facets}
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
/> />
); );
} }

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Genre = { id: number; name: string }; type Genre = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -31,40 +32,62 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
return ( return (
<div> <div>
<h1>Genres</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
<input className="form-control" placeholder="Search genres" value={q} onChange={(e) => setQ(e.target.value)} /> { label: "Genres" },
</div> ]}
<div className="col-auto"> />
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>} <div>
{data && data.items.length > 0 && ( <h1 className="mb-1">Genres</h1>
<div className="table-responsive"> <div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
<table className="table table-striped table-hover align-middle"> </div>
<thead> </div>
<tr>
<th style={{ width: 120 }}>ID</th> <div className="row g-3">
<th>Name</th> <div className="col-lg-3">
</tr> <div className="card shadow-sm">
</thead> <div className="card-body">
<tbody> <form className="d-flex flex-column gap-2" onSubmit={submit}>
{data.items.map((g) => ( <div>
<tr key={g.id}> <label className="form-label small text-secondary">Search</label>
<td><span className="badge text-bg-light">#{g.id}</span></td> <input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
<td> </div>
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link> <div className="d-grid">
</td> <button className="btn btn-primary">Search</button>
</tr> </div>
))} </form>
</tbody> </div>
</table>
</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>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getIssue } from "@/server/repo/zxdb"; import { getIssue } from "@/server/repo/zxdb";
import EntryLink from "@/app/zxdb/components/EntryLink"; import EntryLink from "@/app/zxdb/components/EntryLink";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Issue" }; export const metadata = { title: "ZXDB Issue" };
export const revalidate = 3600; export const revalidate = 3600;
@@ -18,6 +19,15 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
return ( return (
<div> <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"> <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/${issue.magazine.id}`}> Back to magazine</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -33,44 +34,66 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
return ( return (
<div> <div>
<h1>Labels</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
<input className="form-control" placeholder="Search labels" value={q} onChange={(e) => setQ(e.target.value)} /> { label: "Labels" },
</div> ]}
<div className="col-auto"> />
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>} <div>
{data && data.items.length > 0 && ( <h1 className="mb-1">Labels</h1>
<div className="table-responsive"> <div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
<table className="table table-striped table-hover align-middle"> </div>
<thead> </div>
<tr>
<th style={{ width: 100 }}>ID</th> <div className="row g-3">
<th>Name</th> <div className="col-lg-3">
<th style={{ width: 120 }}>Type</th> <div className="card shadow-sm">
</tr> <div className="card-body">
</thead> <form className="d-flex flex-column gap-2" onSubmit={submit}>
<tbody> <div>
{data.items.map((l) => ( <label className="form-label small text-secondary">Search</label>
<tr key={l.id}> <input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
<td>#{l.id}</td> </div>
<td> <div className="d-grid">
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link> <button className="btn btn-primary">Search</button>
</td> </div>
<td> </form>
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span> </div>
</td>
</tr>
))}
</tbody>
</table>
</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>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">

View File

@@ -5,7 +5,31 @@ import EntryLink from "../../components/EntryLink";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
type Label = { id: number; name: string; labeltypeId: string | null }; 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 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 Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -32,7 +56,100 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 className="mb-0">{initial.label.name}</h1> <h1 className="mb-0">{initial.label.name}</h1>
<div> <div>
<span className="badge text-bg-light">{initial.label.labeltypeId ?? "?"}</span> <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>
</div> </div>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Language = { id: string; name: string }; type Language = { id: string; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -31,40 +32,62 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
return ( return (
<div> <div>
<h1>Languages</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
<input className="form-control" placeholder="Search languages" value={q} onChange={(e) => setQ(e.target.value)} /> { label: "Languages" },
</div> ]}
<div className="col-auto"> />
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>} <div>
{data && data.items.length > 0 && ( <h1 className="mb-1">Languages</h1>
<div className="table-responsive"> <div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
<table className="table table-striped table-hover align-middle"> </div>
<thead> </div>
<tr>
<th style={{ width: 120 }}>Code</th> <div className="row g-3">
<th>Name</th> <div className="col-lg-3">
</tr> <div className="card shadow-sm">
</thead> <div className="card-body">
<tbody> <form className="d-flex flex-column gap-2" onSubmit={submit}>
{data.items.map((l) => ( <div>
<tr key={l.id}> <label className="form-label small text-secondary">Search</label>
<td><span className="badge text-bg-light">{l.id}</span></td> <input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
<td> </div>
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link> <div className="d-grid">
</td> <button className="btn btn-primary">Search</button>
</tr> </div>
))} </form>
</tbody> </div>
</table>
</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>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type MT = { id: number; name: string }; type MT = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -33,40 +34,62 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
return ( return (
<div> <div>
<h1>Machine Types</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
<input className="form-control" placeholder="Search machine types" value={q} onChange={(e) => setQ(e.target.value)} /> { label: "Machine Types" },
</div> ]}
<div className="col-auto"> />
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>} <div>
{data && data.items.length > 0 && ( <h1 className="mb-1">Machine Types</h1>
<div className="table-responsive"> <div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
<table className="table table-striped table-hover align-middle"> </div>
<thead> </div>
<tr>
<th style={{ width: 120 }}>ID</th> <div className="row g-3">
<th>Name</th> <div className="col-lg-3">
</tr> <div className="card shadow-sm">
</thead> <div className="card-body">
<tbody> <form className="d-flex flex-column gap-2" onSubmit={submit}>
{data.items.map((m) => ( <div>
<tr key={m.id}> <label className="form-label small text-secondary">Search</label>
<td><span className="badge text-bg-light">#{m.id}</span></td> <input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
<td> </div>
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link> <div className="d-grid">
</td> <button className="btn btn-primary">Search</button>
</tr> </div>
))} </form>
</tbody> </div>
</table>
</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>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getMagazine } from "@/server/repo/zxdb"; import { getMagazine } from "@/server/repo/zxdb";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazine" }; export const metadata = { title: "ZXDB Magazine" };
export const revalidate = 3600; export const revalidate = 3600;
@@ -15,6 +16,14 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
return ( return (
<div> <div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines", href: "/zxdb/magazines" },
{ label: mag.title },
]}
/>
<h1 className="mb-1">{mag.title}</h1> <h1 className="mb-1">{mag.title}</h1>
<div className="text-secondary mb-3">Language: {mag.languageId}</div> <div className="text-secondary mb-3">Language: {mag.languageId}</div>

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { listMagazines } from "@/server/repo/zxdb"; import { listMagazines } from "@/server/repo/zxdb";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazines" }; export const metadata = { title: "ZXDB Magazines" };
@@ -19,30 +20,65 @@ export default async function Page({
return ( return (
<div> <div>
<h1 className="mb-3">Magazines</h1> <ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines" },
]}
/>
<form className="mb-3" action="/zxdb/magazines" method="get"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div className="input-group"> <div>
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} /> <h1 className="mb-1">Magazines</h1>
<button className="btn btn-outline-secondary" type="submit"> <div className="text-secondary">{data.total.toLocaleString()} results</div>
<span className="bi bi-search" aria-hidden />
<span className="visually-hidden">Search</span>
</button>
</div> </div>
</form> </div>
<div className="list-group"> <div className="row g-3">
{data.items.map((m) => ( <div className="col-lg-3">
<Link key={m.id} className="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href={`/zxdb/magazines/${m.id}`}> <div className="card shadow-sm">
<span> <div className="card-body">
{m.title} <form className="d-flex flex-column gap-2" action="/zxdb/magazines" method="get">
<span className="text-secondary ms-2">({m.languageId})</span> <div>
</span> <label className="form-label small text-secondary">Search</label>
<span className="badge bg-secondary rounded-pill" title="Issues"> <input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
{m.issueCount} </div>
</span> <div className="d-grid">
</Link> <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> </div>
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} /> <Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} />

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import TapeIdentifier from "./TapeIdentifier";
export const metadata = { export const metadata = {
title: "ZXDB Explorer", title: "ZXDB Explorer",
@@ -8,69 +9,165 @@ export const revalidate = 3600;
export default async function Page() { export default async function Page() {
return ( return (
<div> <div className="d-flex flex-column gap-4">
<h1 className="mb-3">ZXDB Explorer</h1> <section
<p className="text-secondary">Choose what you want to explore.</p> className="rounded-4 p-4 p-lg-5 shadow-sm"
style={{
<div className="row g-3"> background: "linear-gradient(135deg, rgba(13,110,253,0.08), rgba(25,135,84,0.08))",
<div className="col-sm-6 col-lg-4"> }}
<Link href="/zxdb/entries" className="text-decoration-none"> >
<div className="card h-100 shadow-sm"> <div className="row align-items-center g-4">
<div className="card-body d-flex align-items-center"> <div className="col-lg-7">
<div className="me-3" aria-hidden> <div className="d-flex align-items-center gap-2 mb-3">
<span className="bi bi-collection" style={{ fontSize: 28 }} /> <span className="badge text-bg-dark">ZXDB</span>
</div> <span className="badge text-bg-secondary">Explorer</span>
<div> </div>
<h5 className="card-title mb-1">Entries</h5> <h1 className="display-6 mb-3">ZXDB Explorer</h1>
<div className="card-text text-secondary">Browse software entries with filters</div> <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>
</div> </div>
</Link> </div>
</div> </section>
<div className="col-sm-6 col-lg-4"> <section className="row g-3">
<Link href="/zxdb/releases" className="text-decoration-none"> <div className="col-lg-8">
<div className="card h-100 shadow-sm"> <TapeIdentifier />
<div className="card-body d-flex align-items-center"> </div>
<div className="me-3" aria-hidden> <div className="col-lg-4 d-flex align-items-center">
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} /> <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> </div>
<div> </Link>
<h5 className="card-title mb-1">Releases</h5> </div>
<div className="card-text text-secondary">Drill into releases and downloads</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> </div>
</Link> </div>
</div> <div className="col-lg-5">
<div className="col-sm-6 col-lg-4">
<Link href="/zxdb/magazines" className="text-decoration-none">
<div className="card h-100 shadow-sm"> <div className="card h-100 shadow-sm">
<div className="card-body d-flex align-items-center"> <div className="card-body">
<div className="me-3" aria-hidden> <h3 className="h5">How to use this</h3>
<span className="bi bi-journal-text" style={{ fontSize: 28 }} /> <ol className="mb-0 text-secondary small">
</div> <li>Search by title or aliases in Entries.</li>
<div> <li>Open a release to see downloads, scraps, and places.</li>
<h5 className="card-title mb-1">Magazines</h5> <li>Use magazines to find original reviews and references.</li>
<div className="card-text text-secondary">Browse magazines and their issues</div> <li>Follow labels to discover related work.</li>
</div> </ol>
</div> </div>
</div> </div>
</Link> </div>
</div> </section>
</div> </div>
<div className="mt-4">
<h2 className="h5 mb-2">Categories</h2>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link>
</div>
</div>
</div>
); );
} }

View File

@@ -1,15 +1,31 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import EntryLink from "../components/EntryLink"; import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation"; 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 = { type Item = {
entryId: number; entryId: number;
releaseSeq: number; releaseSeq: number;
entryTitle: string; entryTitle: string;
year: number | null; year: number | null;
magrefCount: number;
}; };
type Paged<T> = { type Paged<T> = {
@@ -53,6 +69,7 @@ export default function ReleasesExplorer({
const pathname = usePathname(); const pathname = usePathname();
const [q, setQ] = useState(initialUrlState?.q ?? ""); const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? "");
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1); const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | null>(initial ?? null); const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
@@ -61,7 +78,7 @@ export default function ReleasesExplorer({
// Download-based filters and their option lists // Download-based filters and their option lists
const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? ""); const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? "");
const [dMachinetypeId, setDMachinetypeId] = useState<string>(initialUrlState?.dMachinetypeId ?? ""); const [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.dMachinetypeId));
const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? ""); const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? "");
const [schemetypeId, setSchemetypeId] = useState<string>(initialUrlState?.schemetypeId ?? ""); const [schemetypeId, setSchemetypeId] = useState<string>(initialUrlState?.schemetypeId ?? "");
const [sourcetypeId, setSourcetypeId] = useState<string>(initialUrlState?.sourcetypeId ?? ""); const [sourcetypeId, setSourcetypeId] = useState<string>(initialUrlState?.sourcetypeId ?? "");
@@ -75,18 +92,29 @@ export default function ReleasesExplorer({
const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []); const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []);
const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []); const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []);
const initialLoad = useRef(true); 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 pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
function updateUrl(nextPage = page) { const updateUrl = useCallback((nextPage = page) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(nextPage)); params.set("page", String(nextPage));
if (year) params.set("year", year); if (year) params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
@@ -94,9 +122,9 @@ export default function ReleasesExplorer({
if (isDemo) params.set("isDemo", "1"); if (isDemo) params.set("isDemo", "1");
const qs = params.toString(); const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname); router.replace(qs ? `${pathname}?${qs}` : pathname);
} }, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, page, pathname, router, schemetypeId, sort, sourcetypeId, year]);
async function fetchData(query: string, p: number) { const fetchData = useCallback(async (query: string, p: number) => {
setLoading(true); setLoading(true);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -106,7 +134,7 @@ export default function ReleasesExplorer({
if (year) params.set("year", String(Number(year))); if (year) params.set("year", String(Number(year)));
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
@@ -122,7 +150,7 @@ export default function ReleasesExplorer({
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, pageSize, schemetypeId, sort, sourcetypeId, year]);
useEffect(() => { useEffect(() => {
if (initial) { if (initial) {
@@ -131,21 +159,34 @@ export default function ReleasesExplorer({
} }
}, [initial]); }, [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(() => { useEffect(() => {
const initialPage = initial?.page ?? 1; const initialPage = initial?.page ?? 1;
if ( if (
initial && initial &&
page === initialPage && page === initialPage &&
(initialUrlState?.q ?? "") === q && initialState.q === appliedQ &&
(initialUrlState?.year ?? "") === (year ?? "") && initialState.year === (year ?? "") &&
sort === (initialUrlState?.sort ?? "year_desc") && sort === initialState.sort &&
(initialUrlState?.dLanguageId ?? "") === dLanguageId && initialState.dLanguageId === dLanguageId &&
(initialUrlState?.dMachinetypeId ?? "") === dMachinetypeId && parseMachineIds(initialState.dMachinetypeId).join(",") === dMachinetypeIds.join(",") &&
(initialUrlState?.filetypeId ?? "") === filetypeId && initialState.filetypeId === filetypeId &&
(initialUrlState?.schemetypeId ?? "") === schemetypeId && initialState.schemetypeId === schemetypeId &&
(initialUrlState?.sourcetypeId ?? "") === sourcetypeId && initialState.sourcetypeId === sourcetypeId &&
(initialUrlState?.casetypeId ?? "") === casetypeId && initialState.casetypeId === casetypeId &&
(!!initialUrlState?.isDemo === isDemo) (!!initialState.isDemo === isDemo)
) { ) {
if (initialLoad.current) { if (initialLoad.current) {
initialLoad.current = false; initialLoad.current = false;
@@ -159,14 +200,13 @@ export default function ReleasesExplorer({
if (initial && !initialUrlHasParams) return; if (initial && !initialUrlHasParams) return;
} }
updateUrl(page); updateUrl(page);
fetchData(q, page); fetchData(appliedQ, page);
}, [page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); }, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, fetchData, filetypeId, initial, initialState, initialUrlHasParams, isDemo, page, schemetypeId, sort, sourcetypeId, updateUrl, year]);
function onSubmit(e: React.FormEvent) { function onSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setAppliedQ(q);
setPage(1); setPage(1);
updateUrl(1);
fetchData(q, 1);
} }
// Load filter option lists on mount // Load filter option lists on mount
@@ -193,172 +233,213 @@ export default function ReleasesExplorer({
} }
} }
loadLists(); loadLists();
}, []); }, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]);
const prevHref = useMemo(() => { const prevHref = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); params.set("page", String(Math.max(1, (data?.page ?? 1) - 1)));
if (year) params.set("year", year); if (year) params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId); if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1"); if (isDemo) params.set("isDemo", "1");
return `/zxdb/releases?${params.toString()}`; return `/zxdb/releases?${params.toString()}`;
}, [q, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); }, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
const nextHref = useMemo(() => { const nextHref = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1))); params.set("page", String(Math.max(1, (data?.page ?? 1) + 1)));
if (year) params.set("year", year); if (year) params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId); if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId); if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId); if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1"); if (isDemo) params.set("isDemo", "1");
return `/zxdb/releases?${params.toString()}`; return `/zxdb/releases?${params.toString()}`;
}, [q, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); }, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
return ( return (
<div> <div>
<h1 className="mb-3">Releases</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
<input { label: "Releases" },
type="text" ]}
className="form-control" />
placeholder="Filter by entry title..."
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">
<input
type="number"
className="form-control"
placeholder="Year"
value={year}
onChange={(e) => { setYear(e.target.value); setPage(1); }}
/>
</div>
<div className="col-auto">
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
<option value="">DL Language</option>
{langs.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}>
<option value="">DL Machine</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
<option value="">File type</option>
{filetypes.map((ft) => (
<option key={ft.id} value={ft.id}>{ft.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
<option value="">Scheme</option>
{schemes.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
<option value="">Source</option>
{sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
<option value="">Case</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="col-auto form-check ms-2">
<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 className="col-auto">
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
<option value="year_desc">Sort: Newest</option>
<option value="year_asc">Sort: Oldest</option>
<option value="title">Sort: Title</option>
<option value="entry_id_desc">Sort: Entry ID</option>
</select>
</div>
{loading && (
<div className="col-auto text-secondary">Loading...</div>
)}
</form>
<div className="mt-3"> <ExplorerLayout
{data && data.items.length === 0 && !loading && ( title="Releases"
<div className="alert alert-warning">No results.</div> subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
)} sidebar={(
{data && data.items.length > 0 && ( <FilterSidebar>
<div className="table-responsive"> <form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<table className="table table-striped table-hover align-middle"> <div>
<thead> <label className="form-label small text-secondary">Search title</label>
<tr> <input
<th style={{width: 80}}>Entry ID</th> type="text"
<th>Title</th> className="form-control"
<th style={{width: 140}}>Release #</th> placeholder="Filter by entry title..."
<th style={{width: 100}}>Year</th> value={q}
</tr> onChange={(e) => setQ(e.target.value)}
</thead> />
<tbody> </div>
{data.items.map((it) => ( <div className="d-grid">
<tr key={`${it.entryId}-${it.releaseSeq}`}> <button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
<td> </div>
<EntryLink id={it.entryId} /> <div>
</td> <label className="form-label small text-secondary">Year</label>
<td> <input
<EntryLink id={it.entryId} title={it.entryTitle} /> type="number"
</td> className="form-control"
<td> placeholder="Any"
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}> value={year}
#{it.releaseSeq} onChange={(e) => { setYear(e.target.value); setPage(1); }}
</Link> />
</td> </div>
<td>{it.year ?? <span className="text-secondary">-</span>}</td> <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> </tr>
))} </thead>
</tbody> <tbody>
</table> {data.items.map((it) => (
</div> <tr key={`${it.entryId}-${it.releaseSeq}`}>
)} <td>
</div> <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-2"> <div className="d-flex align-items-center gap-2 mt-4">
<span> <span>Page {data?.page ?? 1} / {totalPages}</span>
Page {data?.page ?? 1} / {totalPages}
</span>
<div className="ms-auto d-flex gap-2"> <div className="ms-auto d-flex gap-2">
<Link <Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}

View File

@@ -1,6 +1,9 @@
"use client"; "use client";
import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type ReleaseDetailData = { type ReleaseDetailData = {
entry: { entry: {
@@ -8,6 +11,10 @@ type ReleaseDetailData = {
title: string; title: string;
issueId: number | null; issueId: number | null;
}; };
entryReleases: Array<{
releaseSeq: number;
year: number | null;
}>;
release: { release: {
entryId: number; entryId: number;
releaseSeq: number; releaseSeq: number;
@@ -38,6 +45,7 @@ type ReleaseDetailData = {
source: { id: string | null; name: string | null }; source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null }; case: { id: string | null; name: string | null };
year: number | null; year: number | null;
localLink?: string | null;
}>; }>;
scraps: Array<{ scraps: Array<{
id: number; id: number;
@@ -53,6 +61,7 @@ type ReleaseDetailData = {
source: { id: string | null; name: string | null }; source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null }; case: { id: string | null; name: string | null };
year: number | null; year: number | null;
localLink?: string | null;
}>; }>;
files: Array<{ files: Array<{
id: number; id: number;
@@ -114,25 +123,96 @@ function formatCurrency(value: number | null, currency: ReleaseDetailData["relea
return String(value); 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 }) { 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>; 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 ( return (
<div> <div>
<nav aria-label="breadcrumb"> <ZxdbBreadcrumbs
<ol className="breadcrumb"> items={[
<li className="breadcrumb-item"> { label: "ZXDB", href: "/zxdb" },
<Link href="/zxdb">ZXDB</Link> { label: "Releases", href: "/zxdb/releases" },
</li> { label: data.entry.title, href: `/zxdb/entries/${data.entry.id}` },
<li className="breadcrumb-item"> { label: `Release #${data.release.releaseSeq}` },
<Link href="/zxdb/releases">Releases</Link> ]}
</li> />
<li className="breadcrumb-item">
<Link href={`/zxdb/entries/${data.entry.id}`}>{data.entry.title}</Link>
</li>
<li className="breadcrumb-item active" aria-current="page">Release #{data.release.releaseSeq}</li>
</ol>
</nav>
<div className="d-flex align-items-center gap-2 flex-wrap"> <div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">Release #{data.release.releaseSeq}</h1> <h1 className="mb-0">Release #{data.release.releaseSeq}</h1>
@@ -141,303 +221,391 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</Link> </Link>
</div> </div>
<hr /> <div className="row g-3 mt-2">
<div className="col-lg-4">
<div className="table-responsive"> <div className="card shadow-sm mb-3">
<table className="table table-striped table-hover align-middle"> <div className="card-body">
<thead> <h5 className="card-title">Release Summary</h5>
<tr> <div className="table-responsive">
<th style={{ width: 220 }}>Field</th> <table className="table table-sm table-striped align-middle mb-0">
<th>Value</th> <tbody>
</tr> <tr>
</thead> <th style={{ width: 160 }}>Entry</th>
<tbody>
<tr>
<td>Entry</td>
<td>
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
</td>
</tr>
<tr>
<td>Release Sequence</td>
<td>#{data.release.releaseSeq}</td>
</tr>
<tr>
<td>Release Date</td>
<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>
<td>Currency</td>
<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>
<td>Price</td>
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
</tr>
<tr>
<td>Budget Price</td>
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
</tr>
<tr>
<td>Microdrive Price</td>
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
</tr>
<tr>
<td>Disk Price</td>
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
</tr>
<tr>
<td>Cartridge Price</td>
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
</tr>
<tr>
<td>Book ISBN</td>
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
</tr>
<tr>
<td>Book Pages</td>
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
</tr>
</tbody>
</table>
</div>
<hr />
<div>
<h5>Magazine References</h5>
{data.magazineRefs.length === 0 && <div className="text-secondary">No magazine references</div>}
{data.magazineRefs.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Magazine</th>
<th>Issue</th>
<th style={{ width: 120 }}>Type</th>
<th style={{ width: 80 }}>Page</th>
<th style={{ width: 100 }}>Original</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{data.magazineRefs.map((m) => (
<tr key={m.id}>
<td>
{m.magazineId != null ? (
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName ?? `#${m.magazineId}`}</Link>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
<Link href={`/zxdb/issues/${m.issueId}`}>#{m.issueId}</Link>
<div className="text-secondary small">{formatIssue(m.issue) || "-"}</div>
</td>
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
<td>{m.page}</td>
<td>{m.isOriginal ? "Yes" : "No"}</td>
<td>{m.scoreGroup || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>Downloads</h5>
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>}
{data.downloads.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 240 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.downloads.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
return (
<tr key={d.id}>
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
<td> <td>
{isHttp ? ( <Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
) : (
<span>{d.link}</span>
)}
</td> </td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code>{d.md5 ?? "-"}</code></td>
<td>
<div className="d-flex gap-1 flex-wrap">
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{d.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
) : null}
{d.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
) : null}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
</div>
</td>
<td>{d.comments ?? ""}</td>
</tr> </tr>
); <tr>
})} <th>Release Sequence</th>
</tbody> <td>#{data.release.releaseSeq}</td>
</table> </tr>
</div> <tr>
)} <th>Release Date</th>
</div>
<hr />
<div>
<h5>Scraps / Media</h5>
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>}
{data.scraps.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th>Flags</th>
<th>Details</th>
<th>Rationale</th>
</tr>
</thead>
<tbody>
{data.scraps.map((s) => {
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
return (
<tr key={s.id}>
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
<td> <td>
{s.link ? ( {data.release.year != null ? (
isHttp ? ( <span>
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a> {data.release.year}
) : ( {data.release.month != null ? `/${String(data.release.month).padStart(2, "0")}` : ""}
<span>{s.link}</span> {data.release.day != null ? `/${String(data.release.day).padStart(2, "0")}` : ""}
) </span>
) : ( ) : (
<span className="text-secondary">-</span> <span className="text-secondary">-</span>
)} )}
</td> </td>
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
<td>
<div className="d-flex gap-1 flex-wrap">
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
{s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{s.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link>
) : null}
{s.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link>
) : null}
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
</div>
</td>
<td>{s.rationale}</td>
</tr> </tr>
); <tr>
})} <th>Currency</th>
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>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> <td>
{isHttp ? ( {data.release.currency.id ? (
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a> <span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
) : ( ) : (
<span>{f.link}</span> <span className="text-secondary">-</span>
)} )}
</td> </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> </tr>
); <tr>
})} <th>Price</th>
</tbody> <td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
</table> </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>
)}
</div>
<hr /> <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"> <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-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> <Link className="btn btn-sm btn-outline-primary" href="/zxdb/releases">Back to Releases</Link>
</div> </div>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -7,6 +7,16 @@ export const metadata = {
export const dynamic = "force-dynamic"; 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 }> }) { export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams; const sp = await searchParams;
const hasParams = Object.values(sp).some((value) => value !== undefined); const hasParams = Object.values(sp).some((value) => value !== undefined);
@@ -16,8 +26,9 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
const year = yearStr ? Number(yearStr) : undefined; 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 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 dLanguageId = (Array.isArray(sp.dLanguageId) ? sp.dLanguageId[0] : sp.dLanguageId) ?? "";
const dMachinetypeIdStr = (Array.isArray(sp.dMachinetypeId) ? sp.dMachinetypeId[0] : sp.dMachinetypeId) ?? ""; const preferredMachineIds = [27, 26, 8, 9];
const dMachinetypeId = dMachinetypeIdStr ? Number(dMachinetypeIdStr) : undefined; const dMachinetypeIds = parseIdList(sp.dMachinetypeId) ?? preferredMachineIds;
const dMachinetypeIdStr = dMachinetypeIds.join(",");
const filetypeIdStr = (Array.isArray(sp.filetypeId) ? sp.filetypeId[0] : sp.filetypeId) ?? ""; const filetypeIdStr = (Array.isArray(sp.filetypeId) ? sp.filetypeId[0] : sp.filetypeId) ?? "";
const filetypeId = filetypeIdStr ? Number(filetypeIdStr) : undefined; const filetypeId = filetypeIdStr ? Number(filetypeIdStr) : undefined;
const schemetypeId = (Array.isArray(sp.schemetypeId) ? sp.schemetypeId[0] : sp.schemetypeId) ?? ""; const schemetypeId = (Array.isArray(sp.schemetypeId) ? sp.schemetypeId[0] : sp.schemetypeId) ?? "";
@@ -27,7 +38,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined; const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined;
const [initial, langs, machines, filetypes, schemes, sources, cases] = await Promise.all([ const [initial, langs, machines, filetypes, schemes, sources, cases] = await Promise.all([
searchReleases({ page, pageSize: 20, q, year, sort, dLanguageId: dLanguageId || undefined, dMachinetypeId, filetypeId, schemetypeId: schemetypeId || undefined, sourcetypeId: sourcetypeId || undefined, casetypeId: casetypeId || undefined, isDemo }), searchReleases({ page, pageSize: 20, q, year, sort, dLanguageId: dLanguageId || undefined, dMachinetypeId: dMachinetypeIds, filetypeId, schemetypeId: schemetypeId || undefined, sourcetypeId: sourcetypeId || undefined, casetypeId: casetypeId || undefined, isDemo }),
listLanguages(), listLanguages(),
listMachinetypes(), listMachinetypes(),
listFiletypes(), listFiletypes(),

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

@@ -14,7 +14,7 @@ export default function NavbarClient() {
<Nav className="me-auto mb-2 mb-lg-0"> <Nav className="me-auto mb-2 mb-lg-0">
<Link className="nav-link" href="/">Home</Link> <Link className="nav-link" href="/">Home</Link>
<Link className="nav-link" href="/registers">Registers</Link> <Link className="nav-link" href="/registers">Registers</Link>
{/*<Link className="nav-link" href="/zxdb">ZXDB</Link>*/} <Link className="nav-link" href="/zxdb">ZXDB</Link>
</Nav> </Nav>
<ThemeDropdown /> <ThemeDropdown />
@@ -22,4 +22,4 @@ export default function NavbarClient() {
</Container> </Container>
</Navbar> </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>
);
}

View File

@@ -14,6 +14,10 @@ const serverSchema = z.object({
ZXDB_FILE_PREFIX: z.string().optional(), ZXDB_FILE_PREFIX: z.string().optional(),
WOS_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 Configuration
OIDC_PROVIDER_URL: z.string().url().optional(), OIDC_PROVIDER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(),

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext } from "drizzle-orm/mysql-core"; import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext, bigint, timestamp } from "drizzle-orm/mysql-core";
// Minimal subset needed for browsing/searching // Minimal subset needed for browsing/searching
export const entries = mysqlTable("entries", { export const entries = mysqlTable("entries", {
@@ -646,3 +646,16 @@ export const zxsrScores = mysqlTable("zxsr_scores", {
score: varchar("score", { length: 100 }), score: varchar("score", { length: 100 }),
comments: text("comments"), 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));
}