Compare commits
49 Commits
feat/zxdb
...
feature/so
| Author | SHA1 | Date | |
|---|---|---|---|
| fe1dfa4170 | |||
| 5f84f482ab | |||
| 8624050614 | |||
| fc513c580b | |||
| e27a16eda1 | |||
| 5a6c536283 | |||
| 6b91fde972 | |||
| 9efedb5f2e | |||
| 51e1f10417 | |||
| 9bfebc1372 | |||
| edc937ad5d | |||
| f5ae89e888 | |||
| 944a2dc4d1 | |||
| b361201cf2 | |||
| b158bfc4a0 | |||
| 728b36e45e | |||
| f445aabcb4 | |||
| 32985c33b9 | |||
| 2e47b598c1 | |||
| ab7872b610 | |||
| 4b3d1ccc7b | |||
| 77b5e76a08 | |||
| cbee214a6b | |||
| 53eb9a1501 | |||
| 9807005305 | |||
| 24e08ce7b9 | |||
| 00a13e3289 | |||
| 2d4b1b2d5b | |||
| 79d161afe1 | |||
| 8a9c5395bd | |||
| 1e8925e631 | |||
| 2f93ed1774 | |||
| dc6db608cd | |||
| 762d13be55 | |||
| 48d02adbed | |||
| 9bb0a18695 | |||
| 89d48edbd9 | |||
| 0b0dced512 | |||
| e94492eab6 | |||
| 6f7ffa899d | |||
| 84dee2710c | |||
| 5130a72641 | |||
| 964b48abf1 | |||
| d9f55c3eb6 | |||
| 06ddeba9bb | |||
| fb206734db | |||
| e2f6aac856 | |||
| 3e13da5552 | |||
| 0594b34c62 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
22
AGENTS.md
22
AGENTS.md
@@ -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,13 +147,28 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
ZXDB
Submodule ZXDB updated: 3784c91bdd...dc2edad9ec
23
bin/deploy.sh
Executable file
23
bin/deploy.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
deploy_branch="${1:-deploy}"
|
||||||
|
current_branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "Working tree is not clean. Commit or stash changes before deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git ls-files --others --exclude-standard | grep -q .; then
|
||||||
|
echo "Untracked files present. Commit or remove them before deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
git checkout "${current_branch}" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
git checkout "${deploy_branch}"
|
||||||
|
git merge --no-edit "${current_branch}"
|
||||||
|
git push explorer.specnext.dev "${deploy_branch}"
|
||||||
@@ -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
18
bin/setup-zxdb-local.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git_dir="$(git -C ZXDB rev-parse --git-dir)"
|
||||||
|
exclude_file="${git_dir}/info/exclude"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${exclude_file}")"
|
||||||
|
touch "${exclude_file}"
|
||||||
|
|
||||||
|
add_exclude() {
|
||||||
|
local pattern="$1"
|
||||||
|
if ! grep -Fxq "${pattern}" "${exclude_file}"; then
|
||||||
|
printf "%s\n" "${pattern}" >> "${exclude_file}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_exclude "ZXDB_mysql.sql"
|
||||||
|
add_exclude "ZXDB_mysql_STRUCTURE_ONLY.sql"
|
||||||
498
bin/update-software-hashes.mjs
Executable file
498
bin/update-software-hashes.mjs
Executable file
@@ -0,0 +1,498 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Compute MD5, CRC32 and size for the inner tape file inside each download zip.
|
||||||
|
// Populates the `software_hashes` table and exports a JSON snapshot to
|
||||||
|
// data/zxdb/software_hashes.json for reimport after DB wipes.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node bin/update-software-hashes.mjs [flags]
|
||||||
|
//
|
||||||
|
// Flags:
|
||||||
|
// --rebuild-all Ignore state and reprocess every download
|
||||||
|
// --rebuild-missing Only process downloads not yet in software_hashes
|
||||||
|
// --start-from-id=N Start processing from download id N
|
||||||
|
// --export-only Skip processing, just export current table to JSON
|
||||||
|
// --quiet Reduce log output
|
||||||
|
// --verbose Force verbose output (default)
|
||||||
|
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import dotenvExpand from "dotenv-expand";
|
||||||
|
dotenvExpand.expand(dotenv.config());
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { createReadStream } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
|
import { Transform } from "stream";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
// ---- CLI flags ----
|
||||||
|
const ARGV = new Set(process.argv.slice(2));
|
||||||
|
const QUIET = ARGV.has("--quiet");
|
||||||
|
const VERBOSE = ARGV.has("--verbose") || !QUIET;
|
||||||
|
const REBUILD_ALL = ARGV.has("--rebuild-all");
|
||||||
|
const REBUILD_MISSING = ARGV.has("--rebuild-missing");
|
||||||
|
const EXPORT_ONLY = ARGV.has("--export-only");
|
||||||
|
|
||||||
|
// Parse --start-from-id=N
|
||||||
|
let CLI_START_FROM = 0;
|
||||||
|
for (const arg of process.argv.slice(2)) {
|
||||||
|
const m = arg.match(/^--start-from-id=(\d+)$/);
|
||||||
|
if (m) CLI_START_FROM = parseInt(m[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInfo(msg) { if (VERBOSE) console.log(msg); }
|
||||||
|
function logWarn(msg) { console.warn(msg); }
|
||||||
|
function logError(msg) { console.error(msg); }
|
||||||
|
|
||||||
|
// ---- Environment ----
|
||||||
|
const envSchema = z.object({
|
||||||
|
ZXDB_URL: z.string().url().refine((s) => s.startsWith("mysql://"), {
|
||||||
|
message: "ZXDB_URL must be a valid mysql:// URL",
|
||||||
|
}),
|
||||||
|
CDN_CACHE: z.string().min(1, "CDN_CACHE must be set to the local CDN mirror root"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
if (!parsedEnv.success) {
|
||||||
|
logError("Invalid environment variables:\n" + JSON.stringify(parsedEnv.error.format(), null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const { ZXDB_URL, CDN_CACHE } = parsedEnv.data;
|
||||||
|
|
||||||
|
const SNAPSHOT_PATH = path.join(PROJECT_ROOT, "data", "zxdb", "software_hashes.json");
|
||||||
|
const STATE_FILE = path.join(CDN_CACHE, ".update-software-hashes.state.json");
|
||||||
|
|
||||||
|
// Filetype IDs for tape images
|
||||||
|
const TAPE_FILETYPE_IDS = [8, 22];
|
||||||
|
|
||||||
|
// Tape file extensions in priority order (most common first)
|
||||||
|
const TAPE_EXTENSIONS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
|
||||||
|
|
||||||
|
// ---- DB ----
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
uri: ZXDB_URL,
|
||||||
|
connectionLimit: 10,
|
||||||
|
maxPreparedStatements: 256,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Path mapping (mirrors sync-downloads.mjs) ----
|
||||||
|
function toLocalPath(fileLink) {
|
||||||
|
if (fileLink.startsWith("/zxdb/sinclair/")) {
|
||||||
|
return path.join(CDN_CACHE, "SC", fileLink.slice("/zxdb/sinclair".length));
|
||||||
|
}
|
||||||
|
if (fileLink.startsWith("/pub/sinclair/")) {
|
||||||
|
return path.join(CDN_CACHE, "WoS", fileLink.slice("/pub/sinclair".length));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- State management ----
|
||||||
|
async function loadState() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(STATE_FILE, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStateAtomic(state) {
|
||||||
|
const tmp = STATE_FILE + ".tmp";
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
|
||||||
|
await fs.rename(tmp, STATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Zip extraction ----
|
||||||
|
|
||||||
|
// Use Node.js built-in (node:zlib for deflate) + manual zip parsing
|
||||||
|
// to avoid external dependencies. Zip files in ZXDB are simple (no encryption, single file).
|
||||||
|
|
||||||
|
async function extractZipContents(zipPath, contentsDir) {
|
||||||
|
const { execFile } = await import("child_process");
|
||||||
|
const { promisify } = await import("util");
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
await fs.mkdir(contentsDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use system unzip, quoting the path to handle brackets in filenames
|
||||||
|
await execFileAsync("unzip", ["-o", "-d", contentsDir, zipPath], {
|
||||||
|
maxBuffer: 50 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// unzip returns exit code 1 for warnings (e.g. "appears to use backslashes")
|
||||||
|
// which is non-fatal — only fail on actual extraction errors
|
||||||
|
if (err.code !== 1) {
|
||||||
|
throw new Error(`unzip failed for ${zipPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Find tape file inside _CONTENTS ----
|
||||||
|
|
||||||
|
async function findTapeFile(contentsDir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(contentsDir, { recursive: true, withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all tape files grouped by extension priority
|
||||||
|
const candidates = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
const priority = TAPE_EXTENSIONS.indexOf(ext);
|
||||||
|
if (priority === -1) continue;
|
||||||
|
|
||||||
|
const fullPath = path.join(entry.parentPath ?? entry.path, entry.name);
|
||||||
|
candidates.push({ path: fullPath, ext, priority, name: entry.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
// Sort by priority (lowest index = highest priority), then alphabetically
|
||||||
|
candidates.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
// Return the best candidate
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hash computation ----
|
||||||
|
|
||||||
|
async function computeHashes(filePath) {
|
||||||
|
const md5 = createHash("md5");
|
||||||
|
let crc = 0xFFFFFFFF;
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
// CRC32 lookup table
|
||||||
|
const crcTable = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
crcTable[i] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = new Transform({
|
||||||
|
transform(chunk, encoding, callback) {
|
||||||
|
md5.update(chunk);
|
||||||
|
size += chunk.length;
|
||||||
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
|
crc = crcTable[(crc ^ chunk[i]) & 0xFF] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
// Pipe through transform (which computes hashes) and discard output
|
||||||
|
await pipeline(stream, transform, async function* (source) {
|
||||||
|
for await (const _ of source) { /* drain */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
const crc32Final = ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0");
|
||||||
|
return {
|
||||||
|
md5: md5.digest("hex"),
|
||||||
|
crc32: crc32Final,
|
||||||
|
sizeBytes: size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ensure software_hashes table exists ----
|
||||||
|
|
||||||
|
async function ensureTable() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS software_hashes (
|
||||||
|
download_id INT NOT NULL PRIMARY KEY,
|
||||||
|
md5 VARCHAR(32) NOT NULL,
|
||||||
|
crc32 VARCHAR(8) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
inner_path VARCHAR(500) NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sh_md5 (md5),
|
||||||
|
INDEX idx_sh_crc32 (crc32)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON export ----
|
||||||
|
|
||||||
|
async function exportSnapshot() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT download_id, md5, crc32, size_bytes, inner_path, updated_at FROM software_hashes ORDER BY download_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
count: rows.length,
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
download_id: r.download_id,
|
||||||
|
md5: r.md5,
|
||||||
|
crc32: r.crc32,
|
||||||
|
size_bytes: Number(r.size_bytes),
|
||||||
|
inner_path: r.inner_path,
|
||||||
|
updated_at: r.updated_at instanceof Date ? r.updated_at.toISOString() : r.updated_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(path.dirname(SNAPSHOT_PATH), { recursive: true });
|
||||||
|
|
||||||
|
// Atomic write
|
||||||
|
const tmp = SNAPSHOT_PATH + ".tmp";
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(snapshot, null, 2), "utf8");
|
||||||
|
await fs.rename(tmp, SNAPSHOT_PATH);
|
||||||
|
|
||||||
|
logInfo(`Exported ${rows.length} rows to ${SNAPSHOT_PATH}`);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Main processing loop ----
|
||||||
|
|
||||||
|
let currentState = null;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureTable();
|
||||||
|
|
||||||
|
if (EXPORT_ONLY) {
|
||||||
|
const count = await exportSnapshot();
|
||||||
|
logInfo(`Export complete: ${count} rows.`);
|
||||||
|
await pool.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine start point
|
||||||
|
const prior = await loadState();
|
||||||
|
let resumeFrom = CLI_START_FROM;
|
||||||
|
if (!REBUILD_ALL && !CLI_START_FROM && prior?.lastProcessedId) {
|
||||||
|
resumeFrom = prior.lastProcessedId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
currentState = {
|
||||||
|
version: 1,
|
||||||
|
startedAt,
|
||||||
|
updatedAt: startedAt,
|
||||||
|
startFromId: resumeFrom,
|
||||||
|
lastProcessedId: prior?.lastProcessedId ?? -1,
|
||||||
|
processed: 0,
|
||||||
|
hashed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query tape-image downloads
|
||||||
|
const placeholders = TAPE_FILETYPE_IDS.map(() => "?").join(", ");
|
||||||
|
|
||||||
|
let rows;
|
||||||
|
if (REBUILD_MISSING) {
|
||||||
|
// Only fetch downloads that don't already have a hash
|
||||||
|
[rows] = await pool.query(
|
||||||
|
`SELECT d.id, d.file_link, d.file_size FROM downloads d
|
||||||
|
LEFT JOIN software_hashes sh ON sh.download_id = d.id
|
||||||
|
WHERE d.filetype_id IN (${placeholders}) AND sh.download_id IS NULL
|
||||||
|
ORDER BY d.id ASC`,
|
||||||
|
TAPE_FILETYPE_IDS
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
[rows] = await pool.query(
|
||||||
|
`SELECT id, file_link, file_size FROM downloads
|
||||||
|
WHERE filetype_id IN (${placeholders}) AND id >= ?
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[...TAPE_FILETYPE_IDS, resumeFrom]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get total count for progress display
|
||||||
|
const [totalRows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM downloads WHERE filetype_id IN (${placeholders})`,
|
||||||
|
TAPE_FILETYPE_IDS
|
||||||
|
);
|
||||||
|
const total = totalRows[0].cnt;
|
||||||
|
|
||||||
|
const mode = REBUILD_MISSING ? "missing only" : REBUILD_ALL ? "rebuild all" : `from id >= ${resumeFrom}`;
|
||||||
|
logInfo(`Processing ${rows.length} tape-image downloads (total in DB: ${total}, mode: ${mode})`);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let hashed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const { id, file_link: fileLink } = row;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localZip = toLocalPath(fileLink);
|
||||||
|
if (!localZip) {
|
||||||
|
// /denied/ and other non-hosted prefixes — skip silently
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
if (processed % 500 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if zip exists locally
|
||||||
|
try {
|
||||||
|
await fs.access(localZip);
|
||||||
|
} catch {
|
||||||
|
// Zip not synced yet — skip silently
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
if (processed % 500 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check/create _CONTENTS
|
||||||
|
const contentsDir = localZip + "_CONTENTS";
|
||||||
|
let contentsExisted = false;
|
||||||
|
try {
|
||||||
|
await fs.access(contentsDir);
|
||||||
|
contentsExisted = true;
|
||||||
|
} catch {
|
||||||
|
// Need to extract
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentsExisted) {
|
||||||
|
try {
|
||||||
|
await extractZipContents(localZip, contentsDir);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn(` [${id}] Extract failed: ${err.message}`);
|
||||||
|
errors++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tape file
|
||||||
|
const tapeFile = await findTapeFile(contentsDir);
|
||||||
|
if (!tapeFile) {
|
||||||
|
// No tape file found inside zip — unusual but not fatal
|
||||||
|
if (VERBOSE) logWarn(` [${id}] No tape file in ${contentsDir}`);
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hashes
|
||||||
|
const hashes = await computeHashes(tapeFile.path);
|
||||||
|
|
||||||
|
// Relative path inside _CONTENTS for the inner_path column
|
||||||
|
const innerPath = path.relative(contentsDir, tapeFile.path);
|
||||||
|
|
||||||
|
// Upsert
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO software_hashes (download_id, md5, crc32, size_bytes, inner_path, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
md5 = VALUES(md5),
|
||||||
|
crc32 = VALUES(crc32),
|
||||||
|
size_bytes = VALUES(size_bytes),
|
||||||
|
inner_path = VALUES(inner_path),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[id, hashes.md5, hashes.crc32, hashes.sizeBytes, innerPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
hashed++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (processed % 100 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
logInfo(`... processed=${processed}/${rows.length}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError(` [${id}] Unexpected error: ${err.message}`);
|
||||||
|
errors++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
currentState.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final state save
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
|
||||||
|
logInfo(`\nProcessing complete: processed=${processed}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
|
||||||
|
|
||||||
|
// Export snapshot
|
||||||
|
logInfo("\nExporting JSON snapshot...");
|
||||||
|
await exportSnapshot();
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
logInfo("Done.");
|
||||||
|
|
||||||
|
async function checkpoint() {
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Graceful shutdown ----
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
logWarn("\nInterrupted (SIGINT). Writing state...");
|
||||||
|
try {
|
||||||
|
if (currentState) {
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
logWarn(`State saved at: ${STATE_FILE}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state on SIGINT: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
try { await pool.end(); } catch {}
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run
|
||||||
|
main().catch(async (err) => {
|
||||||
|
logError(`Fatal error: ${err.message}\n${err.stack || "<no stack>"}`);
|
||||||
|
try {
|
||||||
|
if (currentState) {
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
currentState.error = { message: err.message, stack: err.stack };
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state on fatal: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
try { await pool.end(); } catch {}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
0
data/zxdb/.gitkeep
Normal file
0
data/zxdb/.gitkeep
Normal file
263686
data/zxdb/software_hashes.json
Normal file
263686
data/zxdb/software_hashes.json
Normal file
File diff suppressed because it is too large
Load Diff
49
docs/ZXDB.md
49
docs/ZXDB.md
@@ -11,6 +11,7 @@ ZXDB ( https://github.com/zxdb/ZXDB )is a community‑maintained database of ZX
|
|||||||
- 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 read‑only MySQL user for the app (recommended).
|
- A read‑only MySQL user for the app (recommended).
|
||||||
|
- The `ZXDB` submodule is checked in for schemas/scripts; use `pnpm setup:zxdb-local` after cloning to keep local SQL dumps untracked.
|
||||||
|
|
||||||
## Database setup
|
## Database setup
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ ZXDB ( https://github.com/zxdb/ZXDB )is a community‑maintained 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 read‑only role/user (recommended).
|
3. Create a read‑only 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.
|
||||||
|
|
||||||
Cross‑linking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched.
|
Cross‑linking: 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 server‑rendering is active and ISR is set; client components should not fetch on the first paint when initial props are provided.
|
- Slow entry page: confirm server‑rendering is active and ISR is set; client components should not fetch on the first paint when initial props are provided.
|
||||||
- MySQL auth or network errors: verify `ZXDB_URL` and that your user has read permissions.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 you’re browsing on GitHub, the main README also links to these documents.
|
If you’re browsing on GitHub, the main README also links to these documents.
|
||||||
|
|||||||
75
docs/plans/plan_feature-software-hashes_implimentation.md
Normal file
75
docs/plans/plan_feature-software-hashes_implimentation.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# WIP: Software Hashes
|
||||||
|
|
||||||
|
**Branch:** `feature/software-hashes`
|
||||||
|
**Started:** 2026-02-17
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Implements [docs/plans/software-hashes.md](software-hashes.md) — a derived `software_hashes` table storing MD5, CRC32 and size for tape-image contents extracted from download zips.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- [x] Create `data/zxdb/` directory (for JSON snapshot)
|
||||||
|
- [x] Add `software_hashes` Drizzle schema model
|
||||||
|
- [x] Create `bin/update-software-hashes.mjs` — main pipeline script
|
||||||
|
- [x] DB query for tape-image downloads (filetype_id IN 8, 22)
|
||||||
|
- [x] Resolve local zip path via CDN mapping (uses CDN_CACHE env var)
|
||||||
|
- [x] Extract `_CONTENTS` (skip if exists)
|
||||||
|
- [x] Find tape file (.tap/.tzx/.pzx/.csw) with priority order
|
||||||
|
- [x] Compute MD5, CRC32, size_bytes
|
||||||
|
- [x] Upsert into software_hashes
|
||||||
|
- [x] State file for resume support
|
||||||
|
- [x] JSON export after bulk update (atomic write)
|
||||||
|
- [x] Update `bin/import_mysql.sh` to reimport snapshot on DB wipe
|
||||||
|
- [x] Add pnpm script entries
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### 2026-02-17T16:00Z
|
||||||
|
- Started work. Branch created from `main` at `b361201`.
|
||||||
|
- Explored codebase: understood DB schema, CDN mapping, import pipeline.
|
||||||
|
- Key findings:
|
||||||
|
- filetype_id 8 = "Tape image" (33,427 rows), 22 = "BUGFIX tape image" (98 rows)
|
||||||
|
- CDN_CACHE = /Volumes/McFiver/CDN, paths: SC/ (zxdb) and WoS/ (pub)
|
||||||
|
- `_CONTENTS` dirs exist in WoS but not yet in SC
|
||||||
|
- data/zxdb/ directory needs creation
|
||||||
|
- import_mysql.sh needs software_hashes reimport step
|
||||||
|
|
||||||
|
### 2026-02-17T16:04Z
|
||||||
|
- Implemented Drizzle schema model for `software_hashes`.
|
||||||
|
- Created `bin/update-software-hashes.mjs` pipeline script.
|
||||||
|
- Updated `bin/import_mysql.sh` with JSON snapshot reimport.
|
||||||
|
- Added `update:hashes` and `export:hashes` pnpm scripts.
|
||||||
|
|
||||||
|
### 2026-02-17T16:09Z
|
||||||
|
- First full run completed successfully:
|
||||||
|
- 33,525 total tape-image downloads in DB
|
||||||
|
- 32,305 rows hashed and inserted into software_hashes
|
||||||
|
- ~1,220 skipped (missing local zips, `/denied/` prefix, `.p` ZX81 files with no tape content)
|
||||||
|
- JSON snapshot exported: 7.2MB, 32,305 rows at `data/zxdb/software_hashes.json`
|
||||||
|
- All plan steps verified working.
|
||||||
|
|
||||||
|
## Decisions & Notes
|
||||||
|
|
||||||
|
- Target filetype IDs: 8 and 22 (tape image + bugfix tape image).
|
||||||
|
- Tape file priority: .tap > .tzx > .pzx > .csw (most common first).
|
||||||
|
- CDN_CACHE comes from env var (not hard-coded, unlike sync-downloads.mjs).
|
||||||
|
- JSON snapshot at data/zxdb/software_hashes.json (7.2MB, committed to repo).
|
||||||
|
- Node.js built-in `crypto` for MD5; custom CRC32 lookup table (no external deps).
|
||||||
|
- `inner_path` column added (not in original plan) to record which file inside the zip was hashed.
|
||||||
|
- `/denied/` and `/nvg/` prefix downloads (~443) are logged and skipped (no local mirror).
|
||||||
|
- `.p` files (ZX81 programs) categorized as tape images but contain no .tap/.tzx/.pzx/.csw — logged as "no tape file".
|
||||||
|
- Uses system `unzip` for extraction (handles bracket-heavy filenames via `execFile` not shell).
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
b361201 - Ready to start adding hashes
|
||||||
|
944a2dc - wip: start feature/software-hashes — init progress tracker
|
||||||
|
f5ae89e - feat: add software_hashes table schema and reimport pipeline
|
||||||
|
edc937a - feat: add update-software-hashes.mjs pipeline script
|
||||||
|
9bfebc1 - feat: add initial software_hashes JSON snapshot (32,305 rows)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# WIP: Tape Identifier Dropzone
|
||||||
|
|
||||||
|
**Branch:** `feature/software-hashes`
|
||||||
|
**Started:** 2026-02-17
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Implements the tape identifier feature from [docs/plans/tape-identifier.md](tape-identifier.md).
|
||||||
|
|
||||||
|
Drop a tape file on the /zxdb page → client computes MD5 + size → server action looks up `software_hashes` → returns identified ZXDB entry.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- [x] Add `lookupByMd5()` to `src/server/repo/zxdb.ts`
|
||||||
|
- [x] Create `src/utils/md5.ts` — pure-JS MD5 for browser
|
||||||
|
- [x] Create `src/app/zxdb/actions.ts` — server action `identifyTape`
|
||||||
|
- [x] Create `src/app/zxdb/TapeIdentifier.tsx` — client component with dropzone
|
||||||
|
- [x] Insert `<TapeIdentifier />` into `src/app/zxdb/page.tsx`
|
||||||
|
- [ ] Verify on http://localhost:4000/zxdb
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### 2026-02-17T00:00
|
||||||
|
- Started work. Continuing on `feature/software-hashes` at `e27a16e`.
|
||||||
|
|
||||||
|
### 2026-02-17T00:01
|
||||||
|
- All implementation complete. Type check passes. Ready for visual verification.
|
||||||
|
|
||||||
|
## Decisions & Notes
|
||||||
|
|
||||||
|
- Uses RSC server actions (not API routes) to discourage bulk scripting.
|
||||||
|
- MD5 computed client-side; file never leaves the browser.
|
||||||
|
- No new npm dependencies — pure-JS MD5 implementation (~130 lines).
|
||||||
|
- TapeIdentifier placed between hero and "Start exploring" grid in a row layout with explanatory text alongside.
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
fc513c5 - wip: start tape identifier — init progress tracker
|
||||||
|
8624050 - feat: add tape identifier dropzone on /zxdb
|
||||||
155
docs/plans/software-hashes.md
Normal file
155
docs/plans/software-hashes.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Software Hashes Plan
|
||||||
|
|
||||||
|
Plan for adding a derived `software_hashes` table, its update pipeline, and JSON snapshot lifecycle to survive DB wipes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Goals and Scope (Plan Step 1)
|
||||||
|
|
||||||
|
- Create and maintain `software_hashes` for (at this stage) tape-image downloads.
|
||||||
|
- Preserve existing `_CONTENTS` folders; only create missing ones.
|
||||||
|
- Export `software_hashes` to JSON after each bulk update.
|
||||||
|
- Reimport `software_hashes` JSON during DB wipe in `bin/import_mysql.sh` (or a helper script it invokes).
|
||||||
|
- Ensure all scripts are idempotent and resume-safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Confirm Pipeline Touchpoints (Plan Step 2)
|
||||||
|
|
||||||
|
- Verify `bin/import_mysql.sh` is the authoritative DB wipe/import entry point.
|
||||||
|
- Confirm `bin/sync-downloads.mjs` remains responsible only for CDN cache sync.
|
||||||
|
- Confirm `src/server/schema/zxdb.ts` uses `downloads.id` as the natural FK target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Define Data Model: `software_hashes` (Plan Step 3)
|
||||||
|
|
||||||
|
### Table naming and FK alignment
|
||||||
|
|
||||||
|
- Table: `software_hashes`.
|
||||||
|
- FK: `download_id` → `downloads.id`.
|
||||||
|
- Column names follow existing DB `snake_case` conventions.
|
||||||
|
|
||||||
|
### Planned columns
|
||||||
|
|
||||||
|
- `download_id` (PK or unique index; FK to `downloads.id`)
|
||||||
|
- `md5`
|
||||||
|
- `crc32`
|
||||||
|
- `size_bytes`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
### Planned indexes / constraints
|
||||||
|
|
||||||
|
- Unique index on `download_id`.
|
||||||
|
- Index on `md5` for reverse lookup.
|
||||||
|
- Index on `crc32` for reverse lookup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Define JSON Snapshot Format (Plan Step 4)
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
- Default: `data/zxdb/software_hashes.json` (or another agreed path).
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exportedAt": "2026-02-17T15:18:00.000Z",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"download_id": 123,
|
||||||
|
"md5": "...",
|
||||||
|
"crc32": "...",
|
||||||
|
"size_bytes": 12345,
|
||||||
|
"updated_at": "2026-02-17T15:18:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Planned import policy
|
||||||
|
|
||||||
|
- If snapshot exists: truncate `software_hashes` and bulk insert.
|
||||||
|
- If snapshot missing: log and continue without error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Implement Tape Image Update Workflow (Plan Step 5)
|
||||||
|
|
||||||
|
### Planned script
|
||||||
|
|
||||||
|
- `bin/update-software-hashes.mjs` (name can be adjusted).
|
||||||
|
|
||||||
|
### Planned input dataset
|
||||||
|
|
||||||
|
- Query `downloads` for tape-image rows (filter by `filetype_id` or joined `filetypes` table).
|
||||||
|
|
||||||
|
### Planned per-item process
|
||||||
|
|
||||||
|
1. Resolve local zip path using the same CDN mapping used by `sync-downloads`.
|
||||||
|
2. Compute `_CONTENTS` folder name: `<zip filename>_CONTENTS` (exact match).
|
||||||
|
3. If `_CONTENTS` exists, keep it untouched.
|
||||||
|
4. If missing, extract zip into `_CONTENTS` using a library that avoids shell expansion issues with brackets.
|
||||||
|
5. Locate tape file inside (`.tap`, `.tzx`, `.pzx`, `.csw`):
|
||||||
|
- Apply a deterministic priority order.
|
||||||
|
- If multiple candidates remain, log and skip (or record ambiguity).
|
||||||
|
6. Compute `md5`, `crc32`, and `size_bytes` for the selected file.
|
||||||
|
7. Upsert into `software_hashes` keyed by `download_id`.
|
||||||
|
|
||||||
|
### Planned error handling
|
||||||
|
|
||||||
|
- Log missing zips or missing tape files.
|
||||||
|
- Continue after recoverable errors; fail only on critical DB errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Implement JSON Export Lifecycle (Plan Step 6)
|
||||||
|
|
||||||
|
- After each bulk update, export `software_hashes` to JSON.
|
||||||
|
- Write atomically (temp file + rename).
|
||||||
|
- Include `exportedAt` timestamp in snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Reimport During Wipe (`bin/import_mysql.sh`) (Plan Step 7)
|
||||||
|
|
||||||
|
### Planned placement
|
||||||
|
|
||||||
|
- Immediately after database creation and ZXDB SQL import completes.
|
||||||
|
|
||||||
|
### Planned behavior
|
||||||
|
|
||||||
|
- Attempt to read JSON snapshot.
|
||||||
|
- If present, truncate and reinsert `software_hashes`.
|
||||||
|
- Log imported row count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Add Idempotency and Resume Support (Plan Step 8)
|
||||||
|
|
||||||
|
- State file similar to `.sync-downloads.state.json` to track last `download_id` processed.
|
||||||
|
- CLI flags:
|
||||||
|
- `--resume` (default)
|
||||||
|
- `--start-from-id`
|
||||||
|
- `--rebuild-all`
|
||||||
|
- Reprocess when zip file size or mtime changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Validation Checklist (Plan Step 9)
|
||||||
|
|
||||||
|
- `_CONTENTS` folders are never deleted.
|
||||||
|
- Hashes match expected MD5/CRC32 for known samples.
|
||||||
|
- JSON snapshot is created and reimported correctly.
|
||||||
|
- Reverse lookup by `md5`/`crc32`/`size_bytes` identifies misnamed files.
|
||||||
|
- Script can resume safely after interruption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Open Questions / Confirmations (Plan Step 10)
|
||||||
|
|
||||||
|
- Final `software_hashes` column list and types.
|
||||||
|
- Exact JSON snapshot path.
|
||||||
|
- Filetype IDs that map to “Tape Image” in `downloads`.
|
||||||
67
docs/plans/tape-identifier.md
Normal file
67
docs/plans/tape-identifier.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Plan: Tape Identifier Dropzone on /zxdb
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We have 32,960 rows in `software_hashes` with MD5, CRC32, size, and inner_path for tape-image contents. This feature exposes that data to users: drop a tape file, get it identified against the ZXDB database.
|
||||||
|
|
||||||
|
Uses RSC (server actions) rather than an API endpoint to make bulk scripted identification harder.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Client-side:** Compute MD5 + file size in the browser, then call a server action with just those two values (file never leaves the client).
|
||||||
|
|
||||||
|
**Server-side:** A Next.js Server Action looks up `software_hashes` by MD5 (and optionally size_bytes for disambiguation), joins to `downloads` and `entries` to return the entry title, download details, and a link.
|
||||||
|
|
||||||
|
**Client-side MD5:** Web Crypto doesn't support MD5. Include a small pure-JS MD5 utility (~80 lines, well-known algorithm). No new npm dependencies.
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### 1. `src/utils/md5.ts` — Pure-JS MD5 for browser use
|
||||||
|
- Exports `async function computeMd5(file: File): Promise<string>`
|
||||||
|
- Reads file as ArrayBuffer, computes MD5, returns hex string
|
||||||
|
- Standard MD5 algorithm implementation, typed for TypeScript
|
||||||
|
|
||||||
|
### 2. `src/app/zxdb/actions.ts` — Server Action
|
||||||
|
- `'use server'` directive
|
||||||
|
- `identifyTape(md5: string, sizeBytes: number)`
|
||||||
|
- Queries `software_hashes` JOIN `downloads` JOIN `entries` by MD5
|
||||||
|
- If multiple matches and size_bytes narrows it, filter further
|
||||||
|
- Returns array of `{ downloadId, entryId, entryTitle, innerPath, md5, crc32, sizeBytes }`
|
||||||
|
|
||||||
|
### 3. `src/app/zxdb/TapeIdentifier.tsx` — Client Component
|
||||||
|
- `'use client'`
|
||||||
|
- States: `idle` → `hashing` → `identifying` → `results` / `not-found`
|
||||||
|
- Dropzone UI:
|
||||||
|
- Dashed border card, large tape icon, "Drop a tape file to identify it"
|
||||||
|
- Lists supported formats: `.tap .tzx .pzx .csw .p .o`
|
||||||
|
- Also has a hidden `<input type="file">` with a "or choose file" link
|
||||||
|
- Drag-over highlight state
|
||||||
|
- On file drop/select:
|
||||||
|
- Validate extension against supported list
|
||||||
|
- Show spinner + "Computing hash..."
|
||||||
|
- Compute MD5 + size client-side
|
||||||
|
- Call server action `identifyTape(md5, size)`
|
||||||
|
- Show spinner + "Searching ZXDB..."
|
||||||
|
- Results view (replaces dropzone):
|
||||||
|
- Match found: entry title as link to `/zxdb/entries/{id}`, inner filename, MD5, file size
|
||||||
|
- Multiple matches: list all
|
||||||
|
- No match: "No matching tape found in ZXDB"
|
||||||
|
- "Identify another tape" button to reset
|
||||||
|
|
||||||
|
### 4. `src/app/zxdb/page.tsx` — Add TapeIdentifier section
|
||||||
|
- Insert `<TapeIdentifier />` as a new section between the hero and "Start exploring" grid
|
||||||
|
- Wrap in a card with distinct styling to make it visually prominent
|
||||||
|
|
||||||
|
### 5. `src/server/repo/zxdb.ts` — Add lookup function
|
||||||
|
- `lookupByMd5(md5: string)` — joins `software_hashes` → `downloads` → `entries`
|
||||||
|
- Returns download_id, entry_id, entry title, inner_path, hash details
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Visit http://localhost:4000/zxdb
|
||||||
|
- Dropzone should be visible and prominent between hero and navigation grid
|
||||||
|
- Drop a known .tap/.tzx file → should show the identified entry with a link
|
||||||
|
- Drop an unknown file → should show "No matching tape found"
|
||||||
|
- Click "Identify another tape" → resets to dropzone
|
||||||
|
- Check file never leaves browser (Network tab: only the server action call with md5 + size)
|
||||||
|
- Verify non-supported extensions are rejected with helpful message
|
||||||
137
docs/plans/zxdb-missing-features.md
Normal file
137
docs/plans/zxdb-missing-features.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# ZXDB Explorer — Missing Features & Gaps
|
||||||
|
|
||||||
|
Audit of the `/zxdb` pages against the ZXDB schema and existing data. Everything listed below is backed by tables already present in the Drizzle schema (`src/server/schema/zxdb.ts`) but not yet surfaced in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Coverage
|
||||||
|
|
||||||
|
| Section | List page | Detail page | Facets/Filters |
|
||||||
|
|----------------|-----------|-------------|---------------------------------|
|
||||||
|
| Entries | Search | Full detail | genre, language, machinetype |
|
||||||
|
| Releases | Search | Downloads, scraps, files, magazine refs | — |
|
||||||
|
| Labels | Search | Authored/published entries, permissions, licenses | — |
|
||||||
|
| Magazines | Search | Issues list | — |
|
||||||
|
| Issues | via magazine | Magazine refs (reviews/references) | — |
|
||||||
|
| Genres | List | Entries by genre | — |
|
||||||
|
| Languages | List | Entries by language | — |
|
||||||
|
| Machine Types | List | Entries by type | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Top-Level Browse Pages
|
||||||
|
|
||||||
|
### 1. Countries
|
||||||
|
- **Tables:** `countries`, `labels.country_id`
|
||||||
|
- **Value:** Browse by country ("all software from Spain", "UK publishers").
|
||||||
|
|
||||||
|
### 2. Tools
|
||||||
|
- **Tables:** `tools`, `tooltypes`
|
||||||
|
- **Value:** Utilities, emulators, and development tools catalogued in ZXDB.
|
||||||
|
|
||||||
|
### 3. Features
|
||||||
|
- **Tables:** `features`
|
||||||
|
- **Value:** Hardware/software features (Multiface, Kempston joystick, etc.).
|
||||||
|
|
||||||
|
### 4. Topics
|
||||||
|
- **Tables:** `topics`, `topictypes`
|
||||||
|
- **Value:** Editorial/thematic groupings used by magazines.
|
||||||
|
|
||||||
|
### 5. Tags / Collections
|
||||||
|
- **Tables:** `tags`, `tagtypes`, `members`
|
||||||
|
- **Value:** Tags are shown per-entry but there is no top-level "browse by tag" page (e.g. all CSSCGC entries, compilations).
|
||||||
|
|
||||||
|
### 6. Licenses
|
||||||
|
- **Tables:** `licenses`, `licensetypes`, `relatedlicenses`, `licensors`
|
||||||
|
- **Value:** Shown per-entry detail but no "browse all licenses" hub (e.g. all games based on a Marvel license).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Cross-Links & Facets on Existing Pages
|
||||||
|
|
||||||
|
### 7. Magazine reviews on Entry detail
|
||||||
|
- Release detail shows magazine refs, but entry detail does **not** aggregate them.
|
||||||
|
- A user viewing an entry cannot see "reviewed in Crash #42, p.34" without drilling into each release.
|
||||||
|
|
||||||
|
### 8. Year / date filter on Entries
|
||||||
|
- ZXDB has `release_year` on releases. No year facet on the entries explorer.
|
||||||
|
- Users cannot browse "all games from 1985".
|
||||||
|
|
||||||
|
### 9. Availability type filter on Entries
|
||||||
|
- `availabletypes` API route exists but is not a facet on the entries explorer.
|
||||||
|
- Would allow filtering by "Never released", "MIA", etc.
|
||||||
|
|
||||||
|
### 10. Max players filter on Entries
|
||||||
|
- `entries.max_players` exists but is not filterable.
|
||||||
|
- Would enable "all multiplayer games".
|
||||||
|
|
||||||
|
### 11. Label type filter on Labels page
|
||||||
|
- `labeltypes` table exists and `roletypes` API is served.
|
||||||
|
- Cannot filter the labels list by type (person / company / team / magazine).
|
||||||
|
|
||||||
|
### 12. Country filter on Labels page
|
||||||
|
- Labels have `country_id` but no filter on the list page.
|
||||||
|
|
||||||
|
### 13. Country / language filter on Magazines page
|
||||||
|
- Magazine list has search but no country or language filter chips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Data on Detail Pages
|
||||||
|
|
||||||
|
### 14. Entry detail: magazine reviews section
|
||||||
|
- `search_by_magrefs` is used in release detail but entry detail does not aggregate magazine references across all releases.
|
||||||
|
- Same issue as #7 — the entry page should show a combined reviews/references panel.
|
||||||
|
|
||||||
|
### 15. Label detail: country display
|
||||||
|
- Labels have `country_id` / `country2_id` but the detail page does not show them.
|
||||||
|
|
||||||
|
### 16. Label detail: Wikipedia / website links
|
||||||
|
- `labels.link_wikipedia` and `labels.link_site` exist but are not displayed on the label detail page.
|
||||||
|
|
||||||
|
### 17. Entry detail: related entries via same license
|
||||||
|
- Licenses are shown per-entry but there is no click-through to "other games with this license".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entirely Unsurfaced Datasets
|
||||||
|
|
||||||
|
### 18. NVGs
|
||||||
|
- **Table:** `nvgs`
|
||||||
|
- Historical download archive metadata. Not exposed anywhere.
|
||||||
|
|
||||||
|
### 19. SPEX entries / authors
|
||||||
|
- **Tables:** `spex_entries`, `spex_authors`
|
||||||
|
- No UI.
|
||||||
|
|
||||||
|
### 20. Awards
|
||||||
|
- **Table:** `zxsr_awards`, referenced by `magrefs.award_id`
|
||||||
|
- No awards browsing or display.
|
||||||
|
|
||||||
|
### 21. Review text
|
||||||
|
- **Table:** `zxsr_reviews` (`intro_text`, `review_text`)
|
||||||
|
- Magazine refs link to reviews by ID but the actual review text is never rendered.
|
||||||
|
|
||||||
|
### 22. Articles
|
||||||
|
- **Tables:** `articles`, `articletypes`
|
||||||
|
- No articles browsing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation / UX Gaps
|
||||||
|
|
||||||
|
### 23. No discovery mechanism
|
||||||
|
- No "random entry", "on this day", or "featured" section. Common for large historic databases.
|
||||||
|
|
||||||
|
### 24. No stats / dashboard
|
||||||
|
- No summary counts ("ZXDB has X entries, Y labels, Z magazines"). Would anchor the landing page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Priority
|
||||||
|
|
||||||
|
| Priority | Items | Rationale |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| High | 7/14 (magazine refs on entry detail), 8 (year filter), 15-16 (label country + links) | Data exists, just not wired up. High user value. |
|
||||||
|
| Medium | 1 (countries), 5 (tags browse), 6 (licenses browse), 9 (availability filter), 24 (stats) | New pages but straightforward queries. |
|
||||||
|
| Low | 2-4 (tools/features/topics), 10-13 (additional filters), 17-22 (unsurfaced datasets), 23 (discovery) | Useful but niche or requires more design work. |
|
||||||
21
example.env
21
example.env
@@ -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=
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
71
src/app/api/zxdb/download/route.ts
Normal file
71
src/app/api/zxdb/download/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const source = searchParams.get("source");
|
||||||
|
const filePath = searchParams.get("path");
|
||||||
|
|
||||||
|
if (!source || !filePath) {
|
||||||
|
return new NextResponse("Missing source or path", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseDir: string | undefined;
|
||||||
|
if (source === "zxdb") {
|
||||||
|
baseDir = env.ZXDB_LOCAL_FILEPATH;
|
||||||
|
} else if (source === "wos") {
|
||||||
|
baseDir = env.WOS_LOCAL_FILEPATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseDir) {
|
||||||
|
return new NextResponse("Invalid source or mirroring not enabled", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Ensure path doesn't escape baseDir
|
||||||
|
const absolutePath = path.normalize(path.join(baseDir, filePath));
|
||||||
|
if (!absolutePath.startsWith(path.normalize(baseDir))) {
|
||||||
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
return new NextResponse("File not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(absolutePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return new NextResponse("Not a file", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(absolutePath);
|
||||||
|
const fileName = path.basename(absolutePath);
|
||||||
|
const ext = path.extname(fileName).toLowerCase();
|
||||||
|
|
||||||
|
// Determine Content-Type
|
||||||
|
let contentType = "application/octet-stream";
|
||||||
|
if (ext === ".txt" || ext === ".nfo") {
|
||||||
|
contentType = "text/plain; charset=utf-8";
|
||||||
|
} else if (ext === ".png") {
|
||||||
|
contentType = "image/png";
|
||||||
|
} else if (ext === ".jpg" || ext === ".jpeg") {
|
||||||
|
contentType = "image/jpeg";
|
||||||
|
} else if (ext === ".gif") {
|
||||||
|
contentType = "image/gif";
|
||||||
|
} else if (ext === ".pdf") {
|
||||||
|
contentType = "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isView = searchParams.get("view") === "1";
|
||||||
|
const disposition = isView ? "inline" : "attachment";
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": `${disposition}; filename="${fileName}"`,
|
||||||
|
"Content-Length": stat.size.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
@@ -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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -1,15 +1,45 @@
|
|||||||
import styles from "./page.module.css";
|
import Link from "next/link";
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className="container-fluid py-4">
|
||||||
<main className={styles.main}>
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body d-flex flex-column gap-3">
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-collection" style={{ fontSize: 40 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h1 className="h3 mb-1">ZXDB Explorer</h1>
|
||||||
|
<p className="text-secondary mb-0">Search entries, releases, magazines, and labels.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-primary" href="/zxdb">Open ZXDB</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">Built for deep linking and fast filters.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/registers">
|
<div className="col-lg-6">
|
||||||
Register Explorer →
|
<div className="card h-100 shadow-sm">
|
||||||
</Link>
|
<div className="card-body d-flex flex-column gap-3">
|
||||||
</main>
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<span className="bi bi-cpu" style={{ fontSize: 40 }} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h2 className="h3 mb-1">NextReg Explorer</h2>
|
||||||
|
<p className="text-secondary mb-0">Browse Spectrum Next registers and bitfields.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-primary" href="/registers">Open registers</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">Parsed locally from official NextReg definitions.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Register, RegisterAccess, Note } from '@/utils/register_parser';
|
import { Register, RegisterAccess, Note } from "@/utils/register_parser";
|
||||||
import { Form, Container, Row, Table, OverlayTrigger, Tooltip } from 'react-bootstrap';
|
import { Form, Row, Table, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
import RegisterDetail from "@/app/registers/RegisterDetail";
|
import RegisterDetail from "@/app/registers/RegisterDetail";
|
||||||
|
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
|
||||||
|
import FilterSidebar from "@/components/explorer/FilterSidebar";
|
||||||
|
|
||||||
interface RegisterBrowserProps {
|
interface RegisterBrowserProps {
|
||||||
registers: Register[];
|
registers: Register[];
|
||||||
@@ -73,7 +75,7 @@ export function renderAccess(access: RegisterAccess, extraNotes: Note[] = []) {
|
|||||||
* @returns A React component that allows users to browse and search registers.
|
* @returns A React component that allows users to browse and search registers.
|
||||||
*/
|
*/
|
||||||
export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
|
export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -102,30 +104,42 @@ export default function RegisterBrowser({ registers }: RegisterBrowserProps) {
|
|||||||
router.replace(url, { scroll: false });
|
router.replace(url, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredRegisters = registers.filter(register =>
|
const filteredRegisters = useMemo(() => (
|
||||||
register.search.includes(searchTerm.toLowerCase())
|
registers.filter((register) => register.search.includes(searchTerm.toLowerCase()))
|
||||||
);
|
), [registers, searchTerm]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container fluid>
|
<ExplorerLayout
|
||||||
<Form.Group className="mb-3">
|
title="NextReg Explorer"
|
||||||
|
subtitle={`${filteredRegisters.length.toLocaleString()} results`}
|
||||||
|
chips={searchTerm ? [`q: ${searchTerm}`] : []}
|
||||||
|
onClearChips={() => {
|
||||||
|
setSearchTerm("");
|
||||||
|
updateQueryString("");
|
||||||
|
}}
|
||||||
|
sidebar={(
|
||||||
|
<FilterSidebar>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label className="form-label small text-secondary">Search</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search registers..."
|
placeholder="Search registers..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setSearchTerm(v);
|
setSearchTerm(v);
|
||||||
updateQueryString(v);
|
updateQueryString(v);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export default async function RegistersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid py-4">
|
<div className="container-fluid py-4">
|
||||||
<h1 className="mb-4">NextReg Explorer</h1>
|
|
||||||
<RegisterBrowser registers={registers} />
|
<RegisterBrowser registers={registers} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
233
src/app/zxdb/TapeIdentifier.tsx
Normal file
233
src/app/zxdb/TapeIdentifier.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { computeMd5 } from "@/utils/md5";
|
||||||
|
import { identifyTape } from "./actions";
|
||||||
|
import type { TapeMatch } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "hashing" }
|
||||||
|
| { kind: "identifying" }
|
||||||
|
| { kind: "results"; matches: TapeMatch[]; fileName: string }
|
||||||
|
| { kind: "not-found"; fileName: string }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TapeIdentifier() {
|
||||||
|
const [state, setState] = useState<State>({ kind: "idle" });
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const processFile = useCallback(async (file: File) => {
|
||||||
|
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
|
||||||
|
if (!SUPPORTED_EXTS.includes(ext)) {
|
||||||
|
setState({
|
||||||
|
kind: "error",
|
||||||
|
message: `Unsupported file type "${ext}". Supported: ${SUPPORTED_EXTS.join(", ")}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ kind: "hashing" });
|
||||||
|
try {
|
||||||
|
const md5 = await computeMd5(file);
|
||||||
|
setState({ kind: "identifying" });
|
||||||
|
const matches = await identifyTape(md5, file.size);
|
||||||
|
if (matches.length > 0) {
|
||||||
|
setState({ kind: "results", matches, fileName: file.name });
|
||||||
|
} else {
|
||||||
|
setState({ kind: "not-found", fileName: file.name });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState({ kind: "error", message: "Something went wrong. Please try again." });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) processFile(file);
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileInput = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) processFile(file);
|
||||||
|
// Reset so re-selecting the same file triggers change
|
||||||
|
e.target.value = "";
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({ kind: "idle" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Dropzone view (idle, hashing, identifying, error)
|
||||||
|
if (state.kind === "results" || state.kind === "not-found") {
|
||||||
|
return (
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
|
||||||
|
Tape Identifier
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{state.kind === "results" ? (
|
||||||
|
<>
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
<strong>{state.fileName}</strong> matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:
|
||||||
|
</p>
|
||||||
|
{state.matches.map((m) => (
|
||||||
|
<div key={m.downloadId} className="card border mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<h6 className="card-title mb-0">
|
||||||
|
<Link href={`/zxdb/entries/${m.entryId}`} className="text-decoration-none">
|
||||||
|
{m.entryTitle}
|
||||||
|
</Link>
|
||||||
|
</h6>
|
||||||
|
{m.releaseYear && (
|
||||||
|
<span className="badge text-bg-secondary ms-2">{m.releaseYear}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(m.authors.length > 0 || m.genre || m.machinetype) && (
|
||||||
|
<div className="d-flex flex-wrap gap-2 mb-2 small text-secondary">
|
||||||
|
{m.authors.length > 0 && (
|
||||||
|
<span><span className="bi bi-person me-1" aria-hidden />{m.authors.join(", ")}</span>
|
||||||
|
)}
|
||||||
|
{m.genre && (
|
||||||
|
<span><span className="bi bi-tag me-1" aria-hidden />{m.genre}</span>
|
||||||
|
)}
|
||||||
|
{m.machinetype && (
|
||||||
|
<span><span className="bi bi-cpu me-1" aria-hidden />{m.machinetype}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="table table-sm table-borderless mb-2 small" style={{ maxWidth: 500 }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0" style={{ width: 90 }}>File</td>
|
||||||
|
<td className="font-monospace">{m.innerPath}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0">Size</td>
|
||||||
|
<td>{formatBytes(m.sizeBytes)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0">MD5</td>
|
||||||
|
<td className="font-monospace">{m.md5}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-secondary ps-0">CRC32</td>
|
||||||
|
<td className="font-monospace">{m.crc32}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/zxdb/entries/${m.entryId}`}
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
>
|
||||||
|
View entry <span className="bi bi-arrow-right ms-1" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
No matching tape found in ZXDB for <strong>{state.fileName}</strong>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn-outline-primary btn-sm" onClick={reset}>
|
||||||
|
Identify another tape
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProcessing = state.kind === "hashing" || state.kind === "identifying";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
|
||||||
|
Tape Identifier
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-3 p-4 text-center ${dragOver ? "bg-primary bg-opacity-10 border-primary" : "border-secondary border-opacity-25"}`}
|
||||||
|
style={{
|
||||||
|
border: "2px dashed",
|
||||||
|
cursor: isProcessing ? "wait" : "pointer",
|
||||||
|
transition: "background-color 0.15s, border-color 0.15s",
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isProcessing) setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={isProcessing ? (e) => e.preventDefault() : handleDrop}
|
||||||
|
onClick={isProcessing ? undefined : () => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="spinner-border spinner-border-sm text-primary me-2" role="status" />
|
||||||
|
<span className="text-secondary">
|
||||||
|
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="bi bi-cloud-arrow-up" style={{ fontSize: 32, opacity: 0.5 }} aria-hidden />
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-secondary">
|
||||||
|
Drop a tape file to identify it
|
||||||
|
</p>
|
||||||
|
<p className="mb-0 small text-secondary">
|
||||||
|
{SUPPORTED_EXTS.join(" ")} — or{" "}
|
||||||
|
<span className="text-primary" style={{ textDecoration: "underline", cursor: "pointer" }}>
|
||||||
|
choose file
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={SUPPORTED_EXTS.join(",")}
|
||||||
|
className="d-none"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.kind === "error" && (
|
||||||
|
<div className="alert alert-warning mt-3 mb-0 py-2 small">
|
||||||
|
{state.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
22
src/app/zxdb/actions.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { lookupByMd5, type TapeMatch } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
|
export async function identifyTape(
|
||||||
|
md5: string,
|
||||||
|
sizeBytes: number
|
||||||
|
): Promise<TapeMatch[]> {
|
||||||
|
// Validate input shape
|
||||||
|
if (!/^[0-9a-f]{32}$/i.test(md5)) return [];
|
||||||
|
if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return [];
|
||||||
|
|
||||||
|
const matches = await lookupByMd5(md5);
|
||||||
|
|
||||||
|
// If multiple matches and size can disambiguate, filter by size
|
||||||
|
if (matches.length > 1) {
|
||||||
|
const bySz = matches.filter((m) => m.sizeBytes === sizeBytes);
|
||||||
|
if (bySz.length > 0) return bySz;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
27
src/app/zxdb/components/ZxdbBreadcrumbs.tsx
Normal file
27
src/app/zxdb/components/ZxdbBreadcrumbs.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Crumb = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ZxdbBreadcrumbs({ items }: { items: Crumb[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const lastIndex = items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol className="breadcrumb">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isActive = index === lastIndex || !item.href;
|
||||||
|
return (
|
||||||
|
<li key={`${item.label}-${index}`} className={`breadcrumb-item${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
|
||||||
|
{isActive ? item.label : <Link href={item.href ?? "#"}>{item.label}</Link>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,38 +215,64 @@ 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" },
|
||||||
|
{ label: "Entries" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExplorerLayout
|
||||||
|
title="Entries"
|
||||||
|
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
|
||||||
|
chips={activeFilters}
|
||||||
|
onClearChips={resetFilters}
|
||||||
|
sidebar={(
|
||||||
|
<FilterSidebar>
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -189,45 +281,93 @@ export default function EntriesExplorer({
|
|||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="d-grid">
|
||||||
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<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); }}>
|
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||||
<option value="">Genre</option>
|
<option value="">All genres</option>
|
||||||
{genres.map((g) => (
|
{genres.map((g) => (
|
||||||
<option key={g.id} value={g.id}>{g.name}</option>
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Language</label>
|
||||||
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
|
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
|
||||||
<option value="">Language</option>
|
<option value="">All languages</option>
|
||||||
{languages.map((l) => (
|
{languages.map((l) => (
|
||||||
<option key={l.id} value={l.id}>{l.name}</option>
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div>
|
||||||
<select className="form-select" value={machinetypeId} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
<label className="form-label small text-secondary">Machine</label>
|
||||||
<option value="">Machine</option>
|
<MultiSelectChips
|
||||||
{machines.map((m) => (
|
options={machineOptions}
|
||||||
<option key={m.id} value={m.id}>{m.name}</option>
|
selected={machinetypeIds}
|
||||||
))}
|
onToggle={(id) => {
|
||||||
</select>
|
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>
|
||||||
<div className="col-auto">
|
<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); }}>
|
<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="title">Title (A–Z)</option>
|
||||||
<option value="id_desc">Sort: Newest</option>
|
<option value="id_desc">Newest</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{loading && (
|
<div>
|
||||||
<div className="col-auto text-secondary">Loading...</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>
|
</form>
|
||||||
|
</FilterSidebar>
|
||||||
<div className="mt-3">
|
)}
|
||||||
|
>
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
@@ -238,6 +378,7 @@ export default function EntriesExplorer({
|
|||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 80 }}>ID</th>
|
<th style={{ width: 80 }}>ID</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Genre</th>
|
||||||
<th style={{ width: 160 }}>Machine</th>
|
<th style={{ width: 160 }}>Machine</th>
|
||||||
<th style={{ width: 120 }}>Language</th>
|
<th style={{ width: 120 }}>Language</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -246,8 +387,17 @@ export default function EntriesExplorer({
|
|||||||
{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" : ""}`}
|
||||||
|
|||||||
@@ -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 Label = { id: number; name: string; labeltypeId: string | null };
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
export type EntryDetailData = {
|
export type EntryDetailData = {
|
||||||
@@ -12,6 +15,66 @@ export type EntryDetailData = {
|
|||||||
genre: { id: number | null; name: string | null };
|
genre: { id: number | null; name: string | null };
|
||||||
authors: Label[];
|
authors: Label[];
|
||||||
publishers: Label[];
|
publishers: Label[];
|
||||||
|
licenses?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
isOfficial: boolean;
|
||||||
|
linkWikipedia?: string | null;
|
||||||
|
linkSite?: string | null;
|
||||||
|
comments?: string | null;
|
||||||
|
}[];
|
||||||
|
relations?: {
|
||||||
|
direction: "from" | "to";
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
entry: { id: number; title: string | null };
|
||||||
|
}[];
|
||||||
|
tags?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
category: { id: number | null; name: string | null };
|
||||||
|
memberSeq: number | null;
|
||||||
|
link: string | null;
|
||||||
|
comments: string | null;
|
||||||
|
}[];
|
||||||
|
ports?: {
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
platform: { id: number; name: string | null };
|
||||||
|
isOfficial: boolean;
|
||||||
|
linkSystem: string | null;
|
||||||
|
}[];
|
||||||
|
remakes?: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
fileLink: string;
|
||||||
|
fileDate: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
authors: string | null;
|
||||||
|
platforms: string | null;
|
||||||
|
remakeYears: string | null;
|
||||||
|
remakeStatus: string | null;
|
||||||
|
}[];
|
||||||
|
scores?: {
|
||||||
|
website: { id: number; name: string | null };
|
||||||
|
score: number;
|
||||||
|
votes: number;
|
||||||
|
}[];
|
||||||
|
notes?: {
|
||||||
|
id: number;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
text: string;
|
||||||
|
}[];
|
||||||
|
origins?: {
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
libraryTitle: string;
|
||||||
|
publication: string | null;
|
||||||
|
containerId: number | null;
|
||||||
|
issueId: number | null;
|
||||||
|
issue: { id: number; magazineId: number | null; magazineTitle: string | null } | null;
|
||||||
|
date: { year: number | null; month: number | null; day: number | null };
|
||||||
|
}[];
|
||||||
// extra fields for richer details
|
// extra fields for richer details
|
||||||
maxPlayers?: number;
|
maxPlayers?: number;
|
||||||
availabletypeId?: string | null;
|
availabletypeId?: string | null;
|
||||||
@@ -42,6 +105,7 @@ export type EntryDetailData = {
|
|||||||
case: { id: string | null; name: string | null };
|
case: { id: string | null; name: string | null };
|
||||||
year: number | null;
|
year: number | null;
|
||||||
releaseSeq: number;
|
releaseSeq: number;
|
||||||
|
localLink?: string | null;
|
||||||
}[];
|
}[];
|
||||||
releases?: {
|
releases?: {
|
||||||
releaseSeq: number;
|
releaseSeq: number;
|
||||||
@@ -64,18 +128,61 @@ export type EntryDetailData = {
|
|||||||
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;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
// Additional relationships
|
// Additional relationships
|
||||||
aliases?: { releaseSeq: number; languageId: string; title: string }[];
|
aliases?: { releaseSeq: number; languageId: string; title: string }[];
|
||||||
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
|
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
|
||||||
|
magazineRefs?: {
|
||||||
|
id: number;
|
||||||
|
issueId: number;
|
||||||
|
magazineId: number | null;
|
||||||
|
magazineName: string | null;
|
||||||
|
referencetypeId: number;
|
||||||
|
referencetypeName: string | null;
|
||||||
|
page: number;
|
||||||
|
isOriginal: number;
|
||||||
|
scoreGroup: string;
|
||||||
|
issue: {
|
||||||
|
dateYear: number | null;
|
||||||
|
dateMonth: number | null;
|
||||||
|
dateDay: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
number: number | null;
|
||||||
|
special: string | null;
|
||||||
|
supplement: string | null;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
|
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
|
||||||
|
const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
const groupedDownloads = useMemo(() => {
|
||||||
|
if (!data?.downloadsFlat) return [];
|
||||||
|
const groups = new Map<string, EntryDetailData["downloadsFlat"]>();
|
||||||
|
for (const d of data.downloadsFlat) {
|
||||||
|
const type = d.type.name;
|
||||||
|
const arr = groups.get(type) ?? [];
|
||||||
|
arr.push(d);
|
||||||
|
groups.set(type, arr);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [data?.downloadsFlat]);
|
||||||
|
|
||||||
if (!data) return <div className="alert alert-warning">Not found</div>;
|
if (!data) return <div className="alert alert-warning">Not found</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<ZxdbBreadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "ZXDB", href: "/zxdb" },
|
||||||
|
{ label: "Entries", href: "/zxdb/entries" },
|
||||||
|
{ label: data.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<h1 className="mb-0">{data.title}</h1>
|
<h1 className="mb-0">{data.title}</h1>
|
||||||
{data.genre.name && (
|
{data.genre.name && (
|
||||||
@@ -96,27 +203,24 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
|
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="row g-3 mt-2">
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Entry Summary</h5>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped table-hover align-middle">
|
<table className="table table-sm table-striped align-middle mb-0">
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: 220 }}>Field</th>
|
|
||||||
<th>Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>ID</td>
|
<th style={{ width: 180 }}>ID</th>
|
||||||
<td>{data.id}</td>
|
<td>{data.id}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Title</td>
|
<th>Title</th>
|
||||||
<td>{data.title}</td>
|
<td>{data.title}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Machine</td>
|
<th>Machine</th>
|
||||||
<td>
|
<td>
|
||||||
{data.machinetype.id != null ? (
|
{data.machinetype.id != null ? (
|
||||||
data.machinetype.name ? (
|
data.machinetype.name ? (
|
||||||
@@ -130,7 +234,7 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Language</td>
|
<th>Language</th>
|
||||||
<td>
|
<td>
|
||||||
{data.language.id ? (
|
{data.language.id ? (
|
||||||
data.language.name ? (
|
data.language.name ? (
|
||||||
@@ -144,7 +248,7 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Genre</td>
|
<th>Genre</th>
|
||||||
<td>
|
<td>
|
||||||
{data.genre.id ? (
|
{data.genre.id ? (
|
||||||
data.genre.name ? (
|
data.genre.name ? (
|
||||||
@@ -159,73 +263,181 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
</tr>
|
</tr>
|
||||||
{typeof data.maxPlayers !== "undefined" && (
|
{typeof data.maxPlayers !== "undefined" && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Max Players</td>
|
<th>Max Players</th>
|
||||||
<td>{data.maxPlayers}</td>
|
<td>{data.maxPlayers}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{typeof data.availabletypeId !== "undefined" && (
|
{typeof data.availabletypeId !== "undefined" && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Available Type</td>
|
<th>Available Type</th>
|
||||||
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
|
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{typeof data.withoutLoadScreen !== "undefined" && (
|
{typeof data.withoutLoadScreen !== "undefined" && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Without Load Screen</td>
|
<th>Without Load Screen</th>
|
||||||
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
|
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{typeof data.withoutInlay !== "undefined" && (
|
{typeof data.withoutInlay !== "undefined" && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Without Inlay</td>
|
<th>Without Inlay</th>
|
||||||
<td>{data.withoutInlay ? "Yes" : "No"}</td>
|
<td>{data.withoutInlay ? "Yes" : "No"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{typeof data.issueId !== "undefined" && (
|
{typeof data.issueId !== "undefined" && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Issue</td>
|
<th>Issue</th>
|
||||||
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
|
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">People</h5>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="text-secondary small mb-1">Authors</div>
|
||||||
|
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||||
|
{data.authors.length > 0 && (
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{data.authors.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="text-secondary small mb-1">Publishers</div>
|
||||||
|
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||||
|
{data.publishers.length > 0 && (
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{data.publishers.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Downloads (flat, by entry_id). Render only this flat section; do not render grouped downloads here. */}
|
<div className="card shadow-sm mb-3">
|
||||||
<div>
|
<div className="card-body">
|
||||||
<h5>Downloads</h5>
|
<h5 className="card-title">Magazine References</h5>
|
||||||
{(!data.downloadsFlat || data.downloadsFlat.length === 0) && <div className="text-secondary">No downloads</div>}
|
{(!data.magazineRefs || data.magazineRefs.length === 0) && <div className="text-secondary">No magazine references recorded</div>}
|
||||||
{data.downloadsFlat && data.downloadsFlat.length > 0 && (
|
{data.magazineRefs && data.magazineRefs.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Magazine</th>
|
||||||
|
<th style={{ width: 140 }}>Issue</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th style={{ width: 120 }}>Page</th>
|
||||||
|
<th>Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.magazineRefs.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>
|
||||||
|
{m.magazineId ? (
|
||||||
|
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{m.magazineName}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/issues/${m.issueId}`}>
|
||||||
|
{m.issue.dateYear ? `${m.issue.dateYear} ` : ""}
|
||||||
|
{m.issue.number ? `#${m.issue.number}` : ""}
|
||||||
|
{m.issue.special ? ` (${m.issue.special})` : ""}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{m.referencetypeName}</td>
|
||||||
|
<td>{m.page > 0 ? m.page : "-"}</td>
|
||||||
|
<td>{m.scoreGroup || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
|
||||||
|
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Downloads</h5>
|
||||||
|
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
||||||
|
{groupedDownloads.map(([type, items]) => (
|
||||||
|
<div key={type} className="mb-4">
|
||||||
|
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm table-striped align-middle">
|
<table className="table table-sm table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
|
||||||
<th>Link</th>
|
<th>Link</th>
|
||||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
<th style={{ width: 100 }} className="text-end">Size</th>
|
||||||
<th style={{ width: 260 }}>MD5</th>
|
<th style={{ width: 180 }}>MD5</th>
|
||||||
<th>Flags</th>
|
<th>Flags</th>
|
||||||
<th>Details</th>
|
<th>Details</th>
|
||||||
<th>Comments</th>
|
<th>Comments</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.downloadsFlat.map((d) => {
|
{items?.map((d) => {
|
||||||
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
|
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 (
|
return (
|
||||||
<tr key={d.id}>
|
<tr key={d.id}>
|
||||||
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
|
||||||
<td>
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
{isHttp ? (
|
{isHttp ? (
|
||||||
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
|
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
|
||||||
) : (
|
) : (
|
||||||
<span>{d.link}</span>
|
<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>
|
||||||
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||||
<td><code>{d.md5 ?? "-"}</code></td>
|
<td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex gap-1 flex-wrap">
|
<div className="d-flex gap-1 flex-wrap">
|
||||||
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
||||||
@@ -243,10 +455,98 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
|
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
|
||||||
)}
|
)}
|
||||||
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
|
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
|
||||||
<span className="badge text-bg-light">rel #{d.releaseSeq}</span>
|
<Link className="badge text-bg-light text-decoration-none" href={`/zxdb/releases/${data.id}/${d.releaseSeq}`}>
|
||||||
|
rel #{d.releaseSeq}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{d.comments ?? ""}</td>
|
<td className="small">{d.comments ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Releases</h5>
|
||||||
|
{(!data.releases || data.releases.length === 0) && <div className="text-secondary">No releases recorded</div>}
|
||||||
|
{data.releases && data.releases.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 120 }}>Release #</th>
|
||||||
|
<th style={{ width: 120 }}>Year</th>
|
||||||
|
<th>Downloads</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.releases.map((r) => (
|
||||||
|
<tr key={r.releaseSeq}>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/releases/${data.id}/${r.releaseSeq}`}>#{r.releaseSeq}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{r.year ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>{r.downloads.length}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Origins</h5>
|
||||||
|
{(!data.origins || data.origins.length === 0) && <div className="text-secondary">No origins recorded</div>}
|
||||||
|
{data.origins && data.origins.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Publication</th>
|
||||||
|
<th style={{ width: 200 }}>Issue</th>
|
||||||
|
<th style={{ width: 140 }}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.origins.map((o, idx) => {
|
||||||
|
const dateParts = [o.date.year, o.date.month, o.date.day]
|
||||||
|
.filter((v) => typeof v === "number" && Number.isFinite(v))
|
||||||
|
.map((v, i) => (i === 0 ? String(v) : String(v).padStart(2, "0")));
|
||||||
|
const dateText = dateParts.length ? dateParts.join("/") : "-";
|
||||||
|
return (
|
||||||
|
<tr key={`${o.type.id}-${idx}`}>
|
||||||
|
<td>{o.type.name ?? o.type.id}</td>
|
||||||
|
<td>{o.libraryTitle}</td>
|
||||||
|
<td>{o.publication ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>
|
||||||
|
{o.issue ? (
|
||||||
|
<div className="d-flex flex-column">
|
||||||
|
<Link href={`/zxdb/issues/${o.issue.id}`}>Issue #{o.issue.id}</Link>
|
||||||
|
{o.issue.magazineId != null && (
|
||||||
|
<Link className="text-secondary small" href={`/zxdb/magazines/${o.issue.magazineId}`}>
|
||||||
|
{o.issue.magazineTitle ?? `Magazine #${o.issue.magazineId}`}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : o.containerId ? (
|
||||||
|
<span>Container #{o.containerId}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{dateText}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -255,43 +555,217 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<div className="row g-4">
|
|
||||||
<div className="col-lg-6">
|
|
||||||
<h5>Authors</h5>
|
|
||||||
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
|
|
||||||
{data.authors.length > 0 && (
|
|
||||||
<ul className="list-unstyled mb-0">
|
|
||||||
{data.authors.map((a) => (
|
|
||||||
<li key={a.id}>
|
|
||||||
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6">
|
|
||||||
<h5>Publishers</h5>
|
<div className="card shadow-sm mb-3">
|
||||||
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>}
|
<div className="card-body">
|
||||||
{data.publishers.length > 0 && (
|
<h5 className="card-title">Relations</h5>
|
||||||
<ul className="list-unstyled mb-0">
|
{(!data.relations || data.relations.length === 0) && <div className="text-secondary">No relations recorded</div>}
|
||||||
{data.publishers.map((p) => (
|
{data.relations && data.relations.length > 0 && (
|
||||||
<li key={p.id}>
|
<div className="table-responsive">
|
||||||
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
|
<table className="table table-sm table-striped align-middle">
|
||||||
</li>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 90 }}>Direction</th>
|
||||||
|
<th style={{ width: 160 }}>Type</th>
|
||||||
|
<th>Entry</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.relations.map((r, idx) => (
|
||||||
|
<tr key={`${r.entry.id}-${r.type.id}-${idx}`}>
|
||||||
|
<td>{r.direction === "from" ? "From" : "To"}</td>
|
||||||
|
<td>{r.type.name ?? r.type.id}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/entries/${r.entry.id}`}>
|
||||||
|
{r.entry.title ?? `Entry #${r.entry.id}`}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Tags / Members</h5>
|
||||||
|
{(!data.tags || data.tags.length === 0) && <div className="text-secondary">No tags recorded</div>}
|
||||||
|
{data.tags && data.tags.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th style={{ width: 140 }}>Category</th>
|
||||||
|
<th style={{ width: 120 }}>Member Seq</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.tags.map((t) => (
|
||||||
|
<tr key={`${t.id}-${t.category.id ?? "none"}`}>
|
||||||
|
<td>{t.name}</td>
|
||||||
|
<td>{t.type.name ?? t.type.id}</td>
|
||||||
|
<td>{t.category.name ?? (t.category.id != null ? `#${t.category.id}` : "-")}</td>
|
||||||
|
<td>{t.memberSeq ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
{t.link && (
|
||||||
|
<a href={t.link} target="_blank" rel="noreferrer">Link</a>
|
||||||
|
)}
|
||||||
|
{t.comments && <span className="text-secondary">{t.comments}</span>}
|
||||||
|
{!t.link && !t.comments && <span className="text-secondary">-</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Aliases (alternative titles) */}
|
<div className="card shadow-sm mb-3">
|
||||||
<div>
|
<div className="card-body">
|
||||||
<h5>Aliases</h5>
|
<h5 className="card-title">Ports</h5>
|
||||||
|
{(!data.ports || data.ports.length === 0) && <div className="text-secondary">No ports recorded</div>}
|
||||||
|
{data.ports && data.ports.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Platform</th>
|
||||||
|
<th style={{ width: 120 }}>Official</th>
|
||||||
|
<th>Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.ports.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.title ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>{p.platform.name ?? `#${p.platform.id}`}</td>
|
||||||
|
<td>{p.isOfficial ? "Yes" : "No"}</td>
|
||||||
|
<td>
|
||||||
|
{p.linkSystem ? (
|
||||||
|
<a href={p.linkSystem} target="_blank" rel="noreferrer">Link</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Remakes</h5>
|
||||||
|
{(!data.remakes || data.remakes.length === 0) && <div className="text-secondary">No remakes recorded</div>}
|
||||||
|
{data.remakes && data.remakes.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style={{ width: 160 }}>Platforms</th>
|
||||||
|
<th style={{ width: 140 }}>Years</th>
|
||||||
|
<th style={{ width: 140 }}>File</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.remakes.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{r.title}</td>
|
||||||
|
<td>{r.platforms ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>{r.remakeYears ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>
|
||||||
|
{r.fileLink ? (
|
||||||
|
<a href={r.fileLink} target="_blank" rel="noreferrer">File</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{r.remakeStatus ?? r.authors ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Scores</h5>
|
||||||
|
{(!data.scores || data.scores.length === 0) && <div className="text-secondary">No scores recorded</div>}
|
||||||
|
{data.scores && data.scores.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th style={{ width: 120 }}>Score</th>
|
||||||
|
<th style={{ width: 120 }}>Votes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.scores.map((s, idx) => (
|
||||||
|
<tr key={`${s.website.id}-${idx}`}>
|
||||||
|
<td>{s.website.name ?? `#${s.website.id}`}</td>
|
||||||
|
<td>{s.score}</td>
|
||||||
|
<td>{s.votes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Notes</h5>
|
||||||
|
{(!data.notes || data.notes.length === 0) && <div className="text-secondary">No notes recorded</div>}
|
||||||
|
{data.notes && data.notes.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th>Text</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.notes.map((n) => (
|
||||||
|
<tr key={n.id}>
|
||||||
|
<td>{n.type.name ?? n.type.id}</td>
|
||||||
|
<td>{n.text}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Aliases</h5>
|
||||||
{(!data.aliases || data.aliases.length === 0) && <div className="text-secondary">No aliases</div>}
|
{(!data.aliases || data.aliases.length === 0) && <div className="text-secondary">No aliases</div>}
|
||||||
{data.aliases && data.aliases.length > 0 && (
|
{data.aliases && data.aliases.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -306,7 +780,9 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data.aliases.map((a, idx) => (
|
{data.aliases.map((a, idx) => (
|
||||||
<tr key={`${a.releaseSeq}-${a.languageId}-${idx}`}>
|
<tr key={`${a.releaseSeq}-${a.languageId}-${idx}`}>
|
||||||
<td>#{a.releaseSeq}</td>
|
<td>
|
||||||
|
<Link href={`/zxdb/releases/${data.id}/${a.releaseSeq}`}>#{a.releaseSeq}</Link>
|
||||||
|
</td>
|
||||||
<td>{a.languageId}</td>
|
<td>{a.languageId}</td>
|
||||||
<td>{a.title}</td>
|
<td>{a.title}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -316,12 +792,52 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Licenses</h5>
|
||||||
|
{(!data.licenses || data.licenses.length === 0) && <div className="text-secondary">No licenses linked</div>}
|
||||||
|
{data.licenses && data.licenses.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th style={{ width: 120 }}>Official</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.licenses.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.name}</td>
|
||||||
|
<td>{l.type.name ?? l.type.id}</td>
|
||||||
|
<td>{l.isOfficial ? "Yes" : "No"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
{l.linkWikipedia && (
|
||||||
|
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
|
||||||
|
)}
|
||||||
|
{l.linkSite && (
|
||||||
|
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
|
||||||
|
)}
|
||||||
|
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Web links (external references) */}
|
<div className="card shadow-sm mb-3">
|
||||||
<div>
|
<div className="card-body">
|
||||||
<h5>Web links</h5>
|
<h5 className="card-title">Web links</h5>
|
||||||
{(!data.webrefs || data.webrefs.length === 0) && <div className="text-secondary">No web links</div>}
|
{(!data.webrefs || data.webrefs.length === 0) && <div className="text-secondary">No web links</div>}
|
||||||
{data.webrefs && data.webrefs.length > 0 && (
|
{data.webrefs && data.webrefs.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -354,11 +870,11 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
<div>
|
<h5 className="card-title">Files</h5>
|
||||||
<h5>Files</h5>
|
|
||||||
{(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>}
|
{(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>}
|
||||||
{data.files && data.files.length > 0 && (
|
{data.files && data.files.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -396,15 +912,16 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
{/* Removed grouped releases/downloads section to avoid duplicate downloads UI. */}
|
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
|
||||||
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
|
|
||||||
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{viewer && (
|
||||||
|
<FileViewer
|
||||||
|
url={viewer.url}
|
||||||
|
title={viewer.title}
|
||||||
|
onClose={() => setViewer(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 +32,38 @@ 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" },
|
||||||
|
{ label: "Genres" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Genres</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="d-grid">
|
||||||
<button className="btn btn-primary">Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<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="alert alert-warning">No genres found.</div>}
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -66,6 +88,7 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
|
|||||||
</div>
|
</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">
|
||||||
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,17 +34,38 @@ 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" },
|
||||||
|
{ label: "Labels" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Labels</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="d-grid">
|
||||||
<button className="btn btn-primary">Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<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="alert alert-warning">No labels found.</div>}
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -72,6 +94,7 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
|
|||||||
</div>
|
</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">
|
||||||
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +32,38 @@ 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" },
|
||||||
|
{ label: "Languages" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Languages</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="d-grid">
|
||||||
<button className="btn btn-primary">Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<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="alert alert-warning">No languages found.</div>}
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -66,6 +88,7 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
|
|||||||
</div>
|
</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">
|
||||||
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
|||||||
@@ -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,17 +34,38 @@ 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" },
|
||||||
|
{ label: "Machine Types" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">Machine Types</h1>
|
||||||
|
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="d-grid">
|
||||||
<button className="btn btn-primary">Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<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="alert alert-warning">No machine types found.</div>}
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -68,6 +90,7 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
|
|||||||
</div>
|
</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">
|
||||||
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<h1 className="mb-1">Magazines</h1>
|
||||||
|
<div className="text-secondary">{data.total.toLocaleString()} results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="d-flex flex-column gap-2" action="/zxdb/magazines" method="get">
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search</label>
|
||||||
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
|
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
|
||||||
<button className="btn btn-outline-secondary" type="submit">
|
</div>
|
||||||
<span className="bi bi-search" aria-hidden />
|
<div className="d-grid">
|
||||||
<span className="visually-hidden">Search</span>
|
<button className="btn btn-primary" type="submit">Search</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="list-group">
|
<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) => (
|
{data.items.map((m) => (
|
||||||
<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}`}>
|
<tr key={m.id}>
|
||||||
<span>
|
<td>
|
||||||
{m.title}
|
<Link href={`/zxdb/magazines/${m.id}`}>{m.title}</Link>
|
||||||
<span className="text-secondary ms-2">({m.languageId})</span>
|
</td>
|
||||||
</span>
|
<td>{m.languageId}</td>
|
||||||
|
<td>
|
||||||
<span className="badge bg-secondary rounded-pill" title="Issues">
|
<span className="badge bg-secondary rounded-pill" title="Issues">
|
||||||
{m.issueCount}
|
{m.issueCount}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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} />
|
||||||
|
|||||||
@@ -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,62 +9,142 @@ export const revalidate = 3600;
|
|||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
return (
|
return (
|
||||||
|
<div className="d-flex flex-column gap-4">
|
||||||
|
<section
|
||||||
|
className="rounded-4 p-4 p-lg-5 shadow-sm"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, rgba(13,110,253,0.08), rgba(25,135,84,0.08))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row align-items-center g-4">
|
||||||
|
<div className="col-lg-7">
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span className="badge text-bg-dark">ZXDB</span>
|
||||||
|
<span className="badge text-bg-secondary">Explorer</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="display-6 mb-3">ZXDB Explorer</h1>
|
||||||
|
<p className="lead text-secondary mb-4">
|
||||||
|
Trace Spectrum-era software across entries, releases, magazines, and labels with deep links and fast filters.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/entries">Browse entries</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/releases">Latest releases</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">Magazine issues</Link>
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">People & labels</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-5">
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title mb-3">Jump straight in</h5>
|
||||||
|
<form className="d-flex flex-column gap-2" method="get" action="/zxdb/entries">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-3">ZXDB Explorer</h1>
|
<label className="form-label small text-secondary">Search entries</label>
|
||||||
<p className="text-secondary">Choose what you want to explore.</p>
|
<input className="form-control" name="q" placeholder="Try: manic, doom, renegade..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Scope</label>
|
||||||
|
<select className="form-select" name="scope" defaultValue="title">
|
||||||
|
<option value="title">Titles</option>
|
||||||
|
<option value="title_aliases">Titles + Aliases</option>
|
||||||
|
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="row g-3">
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<TapeIdentifier />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4 d-flex align-items-center">
|
||||||
|
<p className="text-secondary small mb-0">
|
||||||
|
Drop a <code>.tap</code>, <code>.tzx</code>, or other tape file to identify it against 32,000+ ZXDB entries.
|
||||||
|
The file stays in your browser — only its hash is sent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="h4 mb-0">Start exploring</h2>
|
||||||
|
<span className="text-secondary small">Pick a path to dive deeper</span>
|
||||||
|
</div>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-sm-6 col-lg-4">
|
<div className="col-sm-6 col-lg-3">
|
||||||
<Link href="/zxdb/entries" className="text-decoration-none">
|
<Link href="/zxdb/entries" 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>
|
<div className="d-flex align-items-center gap-3">
|
||||||
<span className="bi bi-collection" style={{ fontSize: 28 }} />
|
<span className="bi bi-collection" style={{ fontSize: 28 }} aria-hidden />
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h5 className="card-title mb-1">Entries</h5>
|
<h5 className="card-title mb-1">Entries</h5>
|
||||||
<div className="card-text text-secondary">Browse software entries with filters</div>
|
<div className="card-text text-secondary">Search + filter titles</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
<div className="col-sm-6 col-lg-4">
|
|
||||||
<Link href="/zxdb/releases" className="text-decoration-none">
|
<Link href="/zxdb/releases" 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>
|
<div className="d-flex align-items-center gap-3">
|
||||||
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} />
|
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} aria-hidden />
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h5 className="card-title mb-1">Releases</h5>
|
<h5 className="card-title mb-1">Releases</h5>
|
||||||
<div className="card-text text-secondary">Drill into releases and downloads</div>
|
<div className="card-text text-secondary">Downloads + media</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
<div className="col-sm-6 col-lg-4">
|
|
||||||
<Link href="/zxdb/magazines" className="text-decoration-none">
|
<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>
|
<div className="d-flex align-items-center gap-3">
|
||||||
<span className="bi bi-journal-text" style={{ fontSize: 28 }} />
|
<span className="bi bi-journal-text" style={{ fontSize: 28 }} aria-hidden />
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h5 className="card-title mb-1">Magazines</h5>
|
<h5 className="card-title mb-1">Magazines</h5>
|
||||||
<div className="card-text text-secondary">Browse magazines and their issues</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="mt-4">
|
<section className="row g-3">
|
||||||
<h2 className="h5 mb-2">Categories</h2>
|
<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">
|
<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/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/languages">Languages</Link>
|
||||||
@@ -72,5 +153,21 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-5">
|
||||||
|
<div className="card h-100 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h5">How to use this</h3>
|
||||||
|
<ol className="mb-0 text-secondary small">
|
||||||
|
<li>Search by title or aliases in Entries.</li>
|
||||||
|
<li>Open a release to see downloads, scraps, and places.</li>
|
||||||
|
<li>Use magazines to find original reviews and references.</li>
|
||||||
|
<li>Follow labels to discover related work.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,45 +233,57 @@ 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" },
|
||||||
|
{ label: "Releases" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExplorerLayout
|
||||||
|
title="Releases"
|
||||||
|
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
|
||||||
|
sidebar={(
|
||||||
|
<FilterSidebar>
|
||||||
|
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Search title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -240,84 +292,103 @@ export default function ReleasesExplorer({
|
|||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="d-grid">
|
||||||
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Year</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="Year"
|
placeholder="Any"
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<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); }}>
|
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
|
||||||
<option value="">DL Language</option>
|
<option value="">All languages</option>
|
||||||
{langs.map((l) => (
|
{langs.map((l) => (
|
||||||
<option key={l.id} value={l.id}>{l.name}</option>
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div>
|
||||||
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}>
|
<label className="form-label small text-secondary">DL Machine</label>
|
||||||
<option value="">DL Machine</option>
|
<MultiSelectChips
|
||||||
{machines.map((m) => (
|
options={machineOptions}
|
||||||
<option key={m.id} value={m.id}>{m.name}</option>
|
selected={dMachinetypeIds}
|
||||||
))}
|
onToggle={(id) => {
|
||||||
</select>
|
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>
|
||||||
<div className="col-auto">
|
<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); }}>
|
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
|
||||||
<option value="">File type</option>
|
<option value="">All file types</option>
|
||||||
{filetypes.map((ft) => (
|
{filetypes.map((ft) => (
|
||||||
<option key={ft.id} value={ft.id}>{ft.name}</option>
|
<option key={ft.id} value={ft.id}>{ft.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Scheme</label>
|
||||||
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
|
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
|
||||||
<option value="">Scheme</option>
|
<option value="">All schemes</option>
|
||||||
{schemes.map((s) => (
|
{schemes.map((s) => (
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Source</label>
|
||||||
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
|
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
|
||||||
<option value="">Source</option>
|
<option value="">All sources</option>
|
||||||
{sources.map((s) => (
|
{sources.map((s) => (
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div>
|
||||||
|
<label className="form-label small text-secondary">Case</label>
|
||||||
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
|
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
|
||||||
<option value="">Case</option>
|
<option value="">All cases</option>
|
||||||
{cases.map((c) => (
|
{cases.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto form-check ms-2">
|
<div className="form-check">
|
||||||
<input id="demoCheck" className="form-check-input" type="checkbox" checked={isDemo} onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }} />
|
<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>
|
<label className="form-check-label" htmlFor="demoCheck">Demo only</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<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); }}>
|
<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_desc">Newest</option>
|
||||||
<option value="year_asc">Sort: Oldest</option>
|
<option value="year_asc">Oldest</option>
|
||||||
<option value="title">Sort: Title</option>
|
<option value="title">Title</option>
|
||||||
<option value="entry_id_desc">Sort: Entry ID</option>
|
<option value="entry_id_desc">Entry ID</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{loading && (
|
{loading && <div className="text-secondary small">Loading...</div>}
|
||||||
<div className="col-auto text-secondary">Loading...</div>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
</FilterSidebar>
|
||||||
<div className="mt-3">
|
)}
|
||||||
|
>
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
@@ -329,6 +400,7 @@ export default function ReleasesExplorer({
|
|||||||
<th style={{ width: 80 }}>Entry ID</th>
|
<th style={{ width: 80 }}>Entry ID</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th style={{ width: 140 }}>Release #</th>
|
<th style={{ width: 140 }}>Release #</th>
|
||||||
|
<th style={{ width: 110 }}>Places</th>
|
||||||
<th style={{ width: 100 }}>Year</th>
|
<th style={{ width: 100 }}>Year</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -339,13 +411,24 @@ export default function ReleasesExplorer({
|
|||||||
<EntryLink id={it.entryId} />
|
<EntryLink id={it.entryId} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<EntryLink id={it.entryId} title={it.entryTitle} />
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
|
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
|
||||||
#{it.releaseSeq}
|
#{it.releaseSeq}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</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>
|
<td>{it.year ?? <span className="text-secondary">-</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -353,12 +436,10 @@ export default function ReleasesExplorer({
|
|||||||
</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" : ""}`}
|
||||||
|
|||||||
@@ -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,29 +221,26 @@ 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="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Release Summary</h5>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped table-hover align-middle">
|
<table className="table table-sm table-striped align-middle mb-0">
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: 220 }}>Field</th>
|
|
||||||
<th>Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Entry</td>
|
<th style={{ width: 160 }}>Entry</th>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
|
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Release Sequence</td>
|
<th>Release Sequence</th>
|
||||||
<td>#{data.release.releaseSeq}</td>
|
<td>#{data.release.releaseSeq}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Release Date</td>
|
<th>Release Date</th>
|
||||||
<td>
|
<td>
|
||||||
{data.release.year != null ? (
|
{data.release.year != null ? (
|
||||||
<span>
|
<span>
|
||||||
@@ -177,7 +254,7 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Currency</td>
|
<th>Currency</th>
|
||||||
<td>
|
<td>
|
||||||
{data.release.currency.id ? (
|
{data.release.currency.id ? (
|
||||||
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
|
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
|
||||||
@@ -187,71 +264,107 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Price</td>
|
<th>Price</th>
|
||||||
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
|
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Budget Price</td>
|
<th>Budget Price</th>
|
||||||
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
|
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Microdrive Price</td>
|
<th>Microdrive Price</th>
|
||||||
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
|
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Disk Price</td>
|
<th>Disk Price</th>
|
||||||
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
|
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Cartridge Price</td>
|
<th>Cartridge Price</th>
|
||||||
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
|
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Book ISBN</td>
|
<th>Book ISBN</th>
|
||||||
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
|
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Book Pages</td>
|
<th>Book Pages</th>
|
||||||
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
|
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</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>
|
<div>
|
||||||
<h5>Magazine References</h5>
|
<Link href={`/zxdb/issues/${issueGroup.issueId}`}>Issue #{issueGroup.issueId}</Link>
|
||||||
{data.magazineRefs.length === 0 && <div className="text-secondary">No magazine references</div>}
|
<div className="text-secondary small">{formatIssue(issueGroup.issue) || "-"}</div>
|
||||||
{data.magazineRefs.length > 0 && (
|
</div>
|
||||||
<div className="table-responsive">
|
<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">
|
<table className="table table-sm table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Magazine</th>
|
|
||||||
<th>Issue</th>
|
|
||||||
<th style={{ width: 120 }}>Type</th>
|
|
||||||
<th style={{ width: 80 }}>Page</th>
|
<th style={{ width: 80 }}>Page</th>
|
||||||
|
<th style={{ width: 120 }}>Type</th>
|
||||||
<th style={{ width: 100 }}>Original</th>
|
<th style={{ width: 100 }}>Original</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.magazineRefs.map((m) => (
|
{issueGroup.items.map((m) => (
|
||||||
<tr key={m.id}>
|
<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.page}</td>
|
||||||
|
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
|
||||||
<td>{m.isOriginal ? "Yes" : "No"}</td>
|
<td>{m.isOriginal ? "Yes" : "No"}</td>
|
||||||
<td>{m.scoreGroup || "-"}</td>
|
<td>{m.scoreGroup || "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -259,43 +372,68 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
<div>
|
<h5 className="card-title">Downloads</h5>
|
||||||
<h5>Downloads</h5>
|
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
||||||
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
{groupedDownloads.map(([type, items]) => (
|
||||||
{data.downloads.length > 0 && (
|
<div key={type} className="mb-4">
|
||||||
|
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm table-striped align-middle">
|
<table className="table table-sm table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
|
||||||
<th>Link</th>
|
<th>Link</th>
|
||||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
<th style={{ width: 100 }} className="text-end">Size</th>
|
||||||
<th style={{ width: 240 }}>MD5</th>
|
<th style={{ width: 180 }}>MD5</th>
|
||||||
<th>Flags</th>
|
<th>Flags</th>
|
||||||
<th>Details</th>
|
<th>Details</th>
|
||||||
<th>Comments</th>
|
<th>Comments</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.downloads.map((d) => {
|
{items?.map((d) => {
|
||||||
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
|
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 (
|
return (
|
||||||
<tr key={d.id}>
|
<tr key={d.id}>
|
||||||
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
|
||||||
<td>
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
{isHttp ? (
|
{isHttp ? (
|
||||||
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
|
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
|
||||||
) : (
|
) : (
|
||||||
<span>{d.link}</span>
|
<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>
|
||||||
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||||
<td><code>{d.md5 ?? "-"}</code></td>
|
<td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex gap-1 flex-wrap">
|
<div className="d-flex gap-1 flex-wrap">
|
||||||
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
||||||
@@ -315,50 +453,71 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
|
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{d.comments ?? ""}</td>
|
<td className="small">{d.comments ?? ""}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
<div>
|
<h5 className="card-title">Scraps / Media</h5>
|
||||||
<h5>Scraps / Media</h5>
|
{groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>}
|
||||||
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>}
|
{groupedScraps.map(([type, items]) => (
|
||||||
{data.scraps.length > 0 && (
|
<div key={type} className="mb-4">
|
||||||
|
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm table-striped align-middle">
|
<table className="table table-sm table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
|
||||||
<th>Link</th>
|
<th>Link</th>
|
||||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
<th style={{ width: 100 }} className="text-end">Size</th>
|
||||||
<th>Flags</th>
|
<th>Flags</th>
|
||||||
<th>Details</th>
|
<th>Details</th>
|
||||||
<th>Rationale</th>
|
<th>Rationale</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.scraps.map((s) => {
|
{items?.map((s) => {
|
||||||
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
|
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 (
|
return (
|
||||||
<tr key={s.id}>
|
<tr key={s.id}>
|
||||||
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
|
|
||||||
<td>
|
<td>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
{s.link ? (
|
{s.link ? (
|
||||||
isHttp ? (
|
isHttp ? (
|
||||||
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a>
|
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
|
||||||
) : (
|
) : (
|
||||||
<span>{s.link}</span>
|
<span className="text-break small">{s.link}</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-secondary">-</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>
|
||||||
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
|
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -380,20 +539,21 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
|
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{s.rationale}</td>
|
<td className="small">{s.rationale}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
<div>
|
<h5 className="card-title">Issue Files</h5>
|
||||||
<h5>Issue Files</h5>
|
|
||||||
{data.files.length === 0 && <div className="text-secondary">No files linked</div>}
|
{data.files.length === 0 && <div className="text-secondary">No files linked</div>}
|
||||||
{data.files.length > 0 && (
|
{data.files.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -431,13 +591,21 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<hr />
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
90
src/components/FileViewer.tsx
Normal file
90
src/components/FileViewer.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
type FileViewerProps = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FileViewer({ url, title, onClose }: FileViewerProps) {
|
||||||
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isText = title.toLowerCase().endsWith(".txt") || title.toLowerCase().endsWith(".nfo");
|
||||||
|
const isImage = title.toLowerCase().match(/\.(png|jpg|jpeg|gif)$/);
|
||||||
|
const isPdf = title.toLowerCase().endsWith(".pdf");
|
||||||
|
|
||||||
|
const viewUrl = url.includes("?") ? `${url}&view=1` : `${url}?view=1`;
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
if (isText) {
|
||||||
|
fetch(viewUrl)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Failed to load file");
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
setContent(text);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show size="xl" onHide={onClose} centered scrollable>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{title}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body className="p-0 bg-dark text-light" style={{ minHeight: "300px" }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: "300px" }}>
|
||||||
|
<Spinner animation="border" variant="light" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<p className="text-danger">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{isText && (
|
||||||
|
<pre className="p-3 m-0" style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: "0.9rem", color: "#ccc" }}>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{isImage && (
|
||||||
|
<div className="text-center p-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={viewUrl} alt={title} className="img-fluid" style={{ maxHeight: "80vh" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPdf && (
|
||||||
|
<iframe src={viewUrl} style={{ width: "100%", height: "80vh", border: "none" }} title={title} />
|
||||||
|
)}
|
||||||
|
{!isText && !isImage && !isPdf && (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<p>Preview not available for this file type.</p>
|
||||||
|
<a href={url} className="btn btn-primary">Download File</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose}>Close</Button>
|
||||||
|
<a href={url} className="btn btn-success" download>Download</a>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
39
src/components/explorer/ExplorerLayout.tsx
Normal file
39
src/components/explorer/ExplorerLayout.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import FilterChips from "./FilterChips";
|
||||||
|
|
||||||
|
type ExplorerLayoutProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
chips?: string[];
|
||||||
|
onClearChips?: () => void;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExplorerLayout({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
chips = [],
|
||||||
|
onClearChips,
|
||||||
|
sidebar,
|
||||||
|
children,
|
||||||
|
}: ExplorerLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1">{title}</h1>
|
||||||
|
{subtitle ? <div className="text-secondary">{subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
{chips.length > 0 ? (
|
||||||
|
<FilterChips chips={chips} onClear={onClearChips} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-lg-3">{sidebar}</div>
|
||||||
|
<div className="col-lg-9">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/explorer/FilterChips.tsx
Normal file
20
src/components/explorer/FilterChips.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
type FilterChipsProps = {
|
||||||
|
chips: string[];
|
||||||
|
onClear?: () => void;
|
||||||
|
clearLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FilterChips({ chips, onClear, clearLabel = "Clear filters" }: FilterChipsProps) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<span key={chip} className="badge text-bg-light">{chip}</span>
|
||||||
|
))}
|
||||||
|
{onClear ? (
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClear}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/explorer/FilterSidebar.tsx
Normal file
13
src/components/explorer/FilterSidebar.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
type FilterSidebarProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FilterSidebar({ children }: FilterSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/explorer/MultiSelectChips.tsx
Normal file
37
src/components/explorer/MultiSelectChips.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
type ChipOption<T extends number | string> = {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultiSelectChipsProps<T extends number | string> = {
|
||||||
|
options: ChipOption<T>[];
|
||||||
|
selected: T[];
|
||||||
|
onToggle: (id: T) => void;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MultiSelectChips<T extends number | string>({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
size = "sm",
|
||||||
|
}: MultiSelectChipsProps<T>) {
|
||||||
|
const btnSize = size === "sm" ? "btn-sm" : "";
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = selected.includes(option.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(option.id)}
|
||||||
|
type="button"
|
||||||
|
className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`}
|
||||||
|
onClick={() => onToggle(option.id)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
164
src/utils/md5.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Pure-JS MD5 for browser use (Web Crypto doesn't support MD5).
|
||||||
|
// Standard RFC 1321 implementation, typed for TypeScript.
|
||||||
|
|
||||||
|
function md5cycle(x: number[], k: number[]) {
|
||||||
|
let a = x[0], b = x[1], c = x[2], d = x[3];
|
||||||
|
|
||||||
|
a = ff(a, b, c, d, k[0], 7, -680876936);
|
||||||
|
d = ff(d, a, b, c, k[1], 12, -389564586);
|
||||||
|
c = ff(c, d, a, b, k[2], 17, 606105819);
|
||||||
|
b = ff(b, c, d, a, k[3], 22, -1044525330);
|
||||||
|
a = ff(a, b, c, d, k[4], 7, -176418897);
|
||||||
|
d = ff(d, a, b, c, k[5], 12, 1200080426);
|
||||||
|
c = ff(c, d, a, b, k[6], 17, -1473231341);
|
||||||
|
b = ff(b, c, d, a, k[7], 22, -45705983);
|
||||||
|
a = ff(a, b, c, d, k[8], 7, 1770035416);
|
||||||
|
d = ff(d, a, b, c, k[9], 12, -1958414417);
|
||||||
|
c = ff(c, d, a, b, k[10], 17, -42063);
|
||||||
|
b = ff(b, c, d, a, k[11], 22, -1990404162);
|
||||||
|
a = ff(a, b, c, d, k[12], 7, 1804603682);
|
||||||
|
d = ff(d, a, b, c, k[13], 12, -40341101);
|
||||||
|
c = ff(c, d, a, b, k[14], 17, -1502002290);
|
||||||
|
b = ff(b, c, d, a, k[15], 22, 1236535329);
|
||||||
|
|
||||||
|
a = gg(a, b, c, d, k[1], 5, -165796510);
|
||||||
|
d = gg(d, a, b, c, k[6], 9, -1069501632);
|
||||||
|
c = gg(c, d, a, b, k[11], 14, 643717713);
|
||||||
|
b = gg(b, c, d, a, k[0], 20, -373897302);
|
||||||
|
a = gg(a, b, c, d, k[5], 5, -701558691);
|
||||||
|
d = gg(d, a, b, c, k[10], 9, 38016083);
|
||||||
|
c = gg(c, d, a, b, k[15], 14, -660478335);
|
||||||
|
b = gg(b, c, d, a, k[4], 20, -405537848);
|
||||||
|
a = gg(a, b, c, d, k[9], 5, 568446438);
|
||||||
|
d = gg(d, a, b, c, k[14], 9, -1019803690);
|
||||||
|
c = gg(c, d, a, b, k[3], 14, -187363961);
|
||||||
|
b = gg(b, c, d, a, k[8], 20, 1163531501);
|
||||||
|
a = gg(a, b, c, d, k[13], 5, -1444681467);
|
||||||
|
d = gg(d, a, b, c, k[2], 9, -51403784);
|
||||||
|
c = gg(c, d, a, b, k[7], 14, 1735328473);
|
||||||
|
b = gg(b, c, d, a, k[12], 20, -1926607734);
|
||||||
|
|
||||||
|
a = hh(a, b, c, d, k[5], 4, -378558);
|
||||||
|
d = hh(d, a, b, c, k[8], 11, -2022574463);
|
||||||
|
c = hh(c, d, a, b, k[11], 16, 1839030562);
|
||||||
|
b = hh(b, c, d, a, k[14], 23, -35309556);
|
||||||
|
a = hh(a, b, c, d, k[1], 4, -1530992060);
|
||||||
|
d = hh(d, a, b, c, k[4], 11, 1272893353);
|
||||||
|
c = hh(c, d, a, b, k[7], 16, -155497632);
|
||||||
|
b = hh(b, c, d, a, k[10], 23, -1094730640);
|
||||||
|
a = hh(a, b, c, d, k[13], 4, 681279174);
|
||||||
|
d = hh(d, a, b, c, k[0], 11, -358537222);
|
||||||
|
c = hh(c, d, a, b, k[3], 16, -722521979);
|
||||||
|
b = hh(b, c, d, a, k[6], 23, 76029189);
|
||||||
|
a = hh(a, b, c, d, k[9], 4, -640364487);
|
||||||
|
d = hh(d, a, b, c, k[12], 11, -421815835);
|
||||||
|
c = hh(c, d, a, b, k[15], 16, 530742520);
|
||||||
|
b = hh(b, c, d, a, k[2], 23, -995338651);
|
||||||
|
|
||||||
|
a = ii(a, b, c, d, k[0], 6, -198630844);
|
||||||
|
d = ii(d, a, b, c, k[7], 10, 1126891415);
|
||||||
|
c = ii(c, d, a, b, k[14], 15, -1416354905);
|
||||||
|
b = ii(b, c, d, a, k[5], 21, -57434055);
|
||||||
|
a = ii(a, b, c, d, k[12], 6, 1700485571);
|
||||||
|
d = ii(d, a, b, c, k[3], 10, -1894986606);
|
||||||
|
c = ii(c, d, a, b, k[10], 15, -1051523);
|
||||||
|
b = ii(b, c, d, a, k[1], 21, -2054922799);
|
||||||
|
a = ii(a, b, c, d, k[8], 6, 1873313359);
|
||||||
|
d = ii(d, a, b, c, k[15], 10, -30611744);
|
||||||
|
c = ii(c, d, a, b, k[6], 15, -1560198380);
|
||||||
|
b = ii(b, c, d, a, k[13], 21, 1309151649);
|
||||||
|
a = ii(a, b, c, d, k[4], 6, -145523070);
|
||||||
|
d = ii(d, a, b, c, k[11], 10, -1120210379);
|
||||||
|
c = ii(c, d, a, b, k[2], 15, 718787259);
|
||||||
|
b = ii(b, c, d, a, k[9], 21, -343485551);
|
||||||
|
|
||||||
|
x[0] = add32(a, x[0]);
|
||||||
|
x[1] = add32(b, x[1]);
|
||||||
|
x[2] = add32(c, x[2]);
|
||||||
|
x[3] = add32(d, x[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
|
||||||
|
a = add32(add32(a, q), add32(x, t));
|
||||||
|
return add32((a << s) | (a >>> (32 - s)), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & c) | (~b & d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & d) | (c & ~d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(b ^ c ^ d, a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(c ^ (b | ~d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function add32(a: number, b: number) {
|
||||||
|
return (a + b) & 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5blk(s: Uint8Array, offset: number): number[] {
|
||||||
|
const md5blks: number[] = [];
|
||||||
|
for (let i = 0; i < 64; i += 4) {
|
||||||
|
md5blks[i >> 2] =
|
||||||
|
s[offset + i] +
|
||||||
|
(s[offset + i + 1] << 8) +
|
||||||
|
(s[offset + i + 2] << 16) +
|
||||||
|
(s[offset + i + 3] << 24);
|
||||||
|
}
|
||||||
|
return md5blks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = "0123456789abcdef".split("");
|
||||||
|
|
||||||
|
function rhex(n: number) {
|
||||||
|
let s = "";
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
s += hex[(n >> (j * 8 + 4)) & 0x0f] + hex[(n >> (j * 8)) & 0x0f];
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5raw(bytes: Uint8Array): string {
|
||||||
|
const n = bytes.length;
|
||||||
|
const state = [1732584193, -271733879, -1732584194, 271733878];
|
||||||
|
|
||||||
|
let i: number;
|
||||||
|
for (i = 64; i <= n; i += 64) {
|
||||||
|
md5cycle(state, md5blk(bytes, i - 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail: copy remaining bytes into a padded buffer
|
||||||
|
const tail = new Uint8Array(64);
|
||||||
|
const remaining = n - (i - 64);
|
||||||
|
for (let j = 0; j < remaining; j++) {
|
||||||
|
tail[j] = bytes[i - 64 + j];
|
||||||
|
}
|
||||||
|
tail[remaining] = 0x80;
|
||||||
|
|
||||||
|
// If remaining >= 56 we need an extra block
|
||||||
|
if (remaining >= 56) {
|
||||||
|
md5cycle(state, md5blk(tail, 0));
|
||||||
|
tail.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append bit length as 64-bit little-endian
|
||||||
|
const bitLen = n * 8;
|
||||||
|
tail[56] = bitLen & 0xff;
|
||||||
|
tail[57] = (bitLen >> 8) & 0xff;
|
||||||
|
tail[58] = (bitLen >> 16) & 0xff;
|
||||||
|
tail[59] = (bitLen >> 24) & 0xff;
|
||||||
|
// For files < 512 MB the high 32 bits are 0; safe for tape images
|
||||||
|
md5cycle(state, md5blk(tail, 0));
|
||||||
|
|
||||||
|
return rhex(state[0]) + rhex(state[1]) + rhex(state[2]) + rhex(state[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a File as ArrayBuffer and returns its MD5 hex digest.
|
||||||
|
export async function computeMd5(file: File): Promise<string> {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
return md5raw(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user