Merge branch 'dev'

This commit is contained in:
2026-06-08 22:50:04 +01:00
105 changed files with 268781 additions and 2598 deletions

View File

@@ -67,7 +67,7 @@ next-explorer/
- `RegisterDetail.tsx`: Client Component that renders a single registers details, including modes, notes, and source modal. - `RegisterDetail.tsx`: Client Component that renders a single registers details, including modes, notes, and source modal.
- `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address. - `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address.
- `src/app/zxdb/`: ZXDB Explorer routes and client components. - `src/app/zxdb/`: ZXDB Explorer routes and client components.
- `page.tsx` + `ZxdbExplorer.tsx`: Search + filters with server-rendered initial content and ISR. - `page.tsx`: ZXDB hub page linking to entries, releases, labels, etc.
- `entries/[id]/page.tsx` + `EntryDetail.tsx`: Entry details (SSR initial data). - `entries/[id]/page.tsx` + `EntryDetail.tsx`: Entry details (SSR initial data).
- `releases/page.tsx` + `ReleasesExplorer.tsx`: Releases search + filters. - `releases/page.tsx` + `ReleasesExplorer.tsx`: Releases search + filters.
- `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail. - `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail.
@@ -108,6 +108,25 @@ Comment what the code does, not what the agent has done. The documentation's pur
- Use `type` for interfaces. - Use `type` for interfaces.
- No `enum`. - No `enum`.
### UI / Bootstrap Patterns
The project uses the **Bootswatch Pulse** theme (purple primary) with `react-bootstrap` and `react-bootstrap-icons`.
- **Always use react-bootstrap components** over raw HTML+className for Bootstrap elements:
- `Card`, `Table`, `Badge`, `Button`, `Alert`, `Form.Control`, `Form.Select`, `Form.Check`, `InputGroup`, `Spinner`, `Collapse` etc.
- Icons from `react-bootstrap-icons` (e.g. `Search`, `ChevronDown`, `Download`, `BoxArrowUpRight`).
- **Match existing patterns** — see `RegisterBrowser.tsx` and `Navbar.tsx` for canonical react-bootstrap usage.
- **Shared explorer components** in `src/components/explorer/`:
- `ExplorerLayout` — two-column layout (sidebar + content).
- `FilterSidebar``Card` wrapper with optional "Reset all filters" button.
- `FilterSection` — collapsible filter group with label, badge, and `Collapse` animation.
- `MultiSelectChips` — chip-toggle selector with optional collapsed summary mode.
- `Pagination` — prev/next with page counter and loading spinner.
- **Stale-while-revalidate pattern** — show previous results at reduced opacity during loading (`className={loading ? "opacity-50" : ""}`), never blank the screen.
- **Empty states** — only show a section/card if it has data. Do not render empty cards with "No X recorded" placeholders; omit them entirely.
- **Tables** — use react-bootstrap `<Table size="sm" striped>` for data tables. Human-readable sizes (KB/MB) over raw bytes. Omit columns that add noise without value (e.g. MD5 hashes).
- **Alerts** — use `<Alert variant="warning">` for "no results" states with actionable suggestions (e.g. offering to broaden filters).
### React / Next.js Patterns ### React / Next.js Patterns
- **Server Components**: - **Server Components**:
@@ -122,7 +141,7 @@ Comment what the code does, not what the agent has done. The documentation's pur
- `RegisterDetail.tsx`: - `RegisterDetail.tsx`:
- Marked with `'use client'`. - Marked with `'use client'`.
- Renders a single register with tabs for different access modes. - Renders a single register with tabs for different access modes.
- ZXDB client components (e.g., `ZxdbExplorer.tsx`, `EntryDetail.tsx`, `labels/*`) receive initial data from the server and keep interactions on the client without blocking the first paint. - ZXDB client components (e.g., `EntriesExplorer.tsx`, `EntryDetail.tsx`, `labels/*`) receive initial data from the server and keep interactions on the client without blocking the first paint.
- **Dynamic Routing**: - **Dynamic Routing**:
- Pages and API routes must await dynamic params in Next.js 15: - Pages and API routes must await dynamic params in Next.js 15:
@@ -134,6 +153,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,9 +166,10 @@ 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.
@@ -163,6 +184,10 @@ Comment what the code does, not what the agent has done. The documentation's pur
- Use `bin/setup-zxdb-local.sh` (or `pnpm setup:zxdb-local`) to add local excludes for SQL files. - Use `bin/setup-zxdb-local.sh` (or `pnpm setup:zxdb-local`) to add local excludes for SQL files.
- deploy workflow: - deploy workflow:
- `bin/deploy.sh` refuses to run with uncommitted or untracked files at the repo root. - `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

1
CLAUDE.md Symbolic link
View File

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

2
ZXDB

Submodule ZXDB updated: 3784c91bdd...dc2edad9ec

View File

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

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

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

View File

@@ -85,9 +85,9 @@ Generally a set bit indicates the property is asserted
0x04 (04) => Config Mapping 0x04 (04) => Config Mapping
config mode only, bootrom disabled config mode only, bootrom disabled
(W) (W)
bit 7 = Reserved, must be 0 bits 7:0 = 16K SRAM bank mapped to 0x0000-0x3FFF (hard reset = 0)
bits 6:0 = 16K SRAM bank mapped to 0x0000-0x3FFF (hard reset = 0) ** On issue 2 pcbs, even multiplies of 256K are unreliable if storing data in sram for the next core started.
** Even multiplies of 256K are unreliable if storing data in sram for the next core started. ** Bit 7 ignored except on issue 5 pcb
0x05 (05) => Peripheral 1 Setting 0x05 (05) => Peripheral 1 Setting
(R/W) (R/W)
@@ -174,15 +174,16 @@ Joystick modes:
01 = Multiface 128 v87.2 (enable port 0xBF, disable port 0x3F) 01 = Multiface 128 v87.2 (enable port 0xBF, disable port 0x3F)
10 = Multiface 128 v87.12 (enable port 0x9F, disable port 0x1F) 10 = Multiface 128 v87.12 (enable port 0x9F, disable port 0x1F)
11 = Multiface 1 (enable port 0x9F, disable port 0x1F) 11 = Multiface 1 (enable port 0x9F, disable port 0x1F)
bit 5 = Reserved, must be zero bit 5 = 1 to swap sd0 and sd1 (hard reset = 0) (config mode only) *
bit 4 = Enable divmmc automap (hard reset = 0) bit 4 = Enable divmmc automap (hard reset = 0)
bit 3 = 1 to reverse left and right mouse buttons (hard reset = 0) bit 3 = 1 to reverse left and right mouse buttons (hard reset = 0)
bit 2 = Reserved, must be zero bit 2 = Reserved, must be 0
bits 1:0 = mouse dpi (hard reset = 01) bits 1:0 = mouse dpi (hard reset = 01)
00 = low dpi 00 = low dpi
01 = default 01 = default
10 = medium dpi 10 = medium dpi
11 = high dpi 11 = high dpi
* only affects future writes to port 0xE7
0x0B (11) => Joystick I/O Mode 0x0B (11) => Joystick I/O Mode
(R/W) (soft reset = 0x01) (R/W) (soft reset = 0x01)
@@ -219,6 +220,7 @@ Sub-minor number
0000 = ZXN Issue 2, XC6SLX16-2FTG256, 128Mbit W25Q128JV, 24bit spi, 64K*8 core size 0000 = ZXN Issue 2, XC6SLX16-2FTG256, 128Mbit W25Q128JV, 24bit spi, 64K*8 core size
0001 = ZXN Issue 3, XC6SLX16-2FTG256, 128Mbit W25Q128JV, 24bit spi, 64K*8 core size 0001 = ZXN Issue 3, XC6SLX16-2FTG256, 128Mbit W25Q128JV, 24bit spi, 64K*8 core size
0010 = ZXN Issue 4, XC7A15T-1CSG324, 256Mbit MX25L25645G, 32bit spi, 64K*34 core size 0010 = ZXN Issue 4, XC7A15T-1CSG324, 256Mbit MX25L25645G, 32bit spi, 64K*34 core size
0011 = ZXN Issue 5, XC7A35T-2CSG324, 256Mbit MX25L25645G, 32bit spi, 64K*34 core size
0x10 (16) => Core Boot 0x10 (16) => Core Boot
(R) (R)
@@ -525,7 +527,7 @@ Writable in config mode only.
the mask with the attribute byte and the PAPER and border colour are again both taken the mask with the attribute byte and the PAPER and border colour are again both taken
from the fallback colour in nextreg 0x4A. from the fallback colour in nextreg 0x4A.
0x43 (67) => ULA Palette Control 0x43 (67) => Palette Control
(R/W) (R/W)
bit 7 = Disable palette write auto-increment (soft reset = 0) bit 7 = Disable palette write auto-increment (soft reset = 0)
bits 6-4 = Select palette for reading or writing (soft reset = 000) bits 6-4 = Select palette for reading or writing (soft reset = 000)
@@ -787,6 +789,7 @@ Writable in config mode only.
bit 6 = 1 to allow peripherals to override the ULA on some even port reads (rotronics wafadrive) bit 6 = 1 to allow peripherals to override the ULA on some even port reads (rotronics wafadrive)
bit 5 = 1 to disable expansion bus nmi debounce (opus discovery) bit 5 = 1 to disable expansion bus nmi debounce (opus discovery)
bit 4 = 1 to propagate the max cpu clock at all times including when the expansion bus is off bit 4 = 1 to propagate the max cpu clock at all times including when the expansion bus is off
bit 3 = 1 to enable +3 fdc signals on expansion bus (issue 5 only)
bits 1-0 = max cpu speed when the expansion bus is on (currently fixed at 00 = 3.5MHz) bits 1-0 = max cpu speed when the expansion bus is on (currently fixed at 00 = 3.5MHz)
0x85,0x84,0x83,0x82 (133-130) => Internal Port Decoding Enables (0x85 is MSB) (soft reset if bit 31 = 1, hard reset if bit 31 = 0 : all 1) 0x85,0x84,0x83,0x82 (133-130) => Internal Port Decoding Enables (0x85 is MSB) (soft reset if bit 31 = 1, hard reset if bit 31 = 0 : all 1)
@@ -824,6 +827,7 @@ Writable in config mode only.
bit 26 = port eff7 pentagon 1024 memory bit 26 = port eff7 pentagon 1024 memory
bit 27 = port 183b,193b,1a3b,1b3b,1c3b,1d3b,1e3b,1f3b z80 ctc bit 27 = port 183b,193b,1a3b,1b3b,1c3b,1d3b,1e3b,1f3b z80 ctc
... ...
...
bit 31 = register reset mode (soft or hard reset selection) bit 31 = register reset mode (soft or hard reset selection)
----- -----
The internal port decoding enables always apply. The internal port decoding enables always apply.
@@ -1202,7 +1206,7 @@ progress is made in the main program.
-- --
0xF0 (240) => XDEV CMD 0xF0 (240) => XDEV CMD
R/W Issue 4 Only - (soft reset = 0x80) R/W Issues 4 and 5 Only - (soft reset = 0x80)
Select Mode Select Mode
(R) (R)
bit 7 = 1 if in select mode bit 7 = 1 if in select mode
@@ -1238,7 +1242,7 @@ R/W Issue 4 Only - (soft reset = 0x80)
*** Exit select mode by writing zero to bit 7; thereafter the particular device is attached to the nextreg *** Exit select mode by writing zero to bit 7; thereafter the particular device is attached to the nextreg
0xF8 (248) => XADC REG 0xF8 (248) => XADC REG
(R/W Issue 4 Only) (hard reset = 0) (R/W Issues 4 and 5 Only) (hard reset = 0)
bit 7 = 1 to write to XADC DRP port, 0 to read from XADC DRP port ** bit 7 = 1 to write to XADC DRP port, 0 to read from XADC DRP port **
bits 6:0 = XADC DRP register address DADDR bits 6:0 = XADC DRP register address DADDR
* An XADC register read or write is/ initiated by writing to this register * An XADC register read or write is/ initiated by writing to this register
@@ -1246,12 +1250,12 @@ R/W Issue 4 Only - (soft reset = 0x80)
** Reads as 0 ** Reads as 0
0xF9 (249) => XADC D0 0xF9 (249) => XADC D0
(R/W Issue 4 Only) (hard reset = 0) (R/W Issues 4 and 5 Only) (hard reset = 0)
bits 7:0 = LSB data connected to XADC DRP data bus D7:0 bits 7:0 = LSB data connected to XADC DRP data bus D7:0
* DRP reads store result here, DRP writes take value from here * DRP reads store result here, DRP writes take value from here
0xFA (250) => XADC D1 0xFA (250) => XADC D1
(R/W Issue 4 Only) (hard reset = 0) (R/W Issues 4 and 5 Only) (hard reset = 0)
bits 7:0 = MSB data connected to XADC DRP data bus D15:8 bits 7:0 = MSB data connected to XADC DRP data bus D15:8
* DRP reads store result here, DRP writes take value from here * DRP reads store result here, DRP writes take value from here

0
data/zxdb/.gitkeep Normal file
View File

263686
data/zxdb/software_hashes.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -39,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
``` ```

89
docs/changelog.md Normal file
View File

@@ -0,0 +1,89 @@
# Changelog 📜
All notable changes to Next Explorer, in roughly chronological order.
---
## 🚧 v0.3.0 — Tapes, Hashes & Downloads *(unreleased, Feb 2026)*
The great "what IS this .tap file?" release. ZXDB downloads become first-class citizens with local mirroring, grouping, inline previews, and now the ability to identify a tape by dropping it on the page.
### ✨ New
- **Tape Identifier** — drag-and-drop a `.tap`/`.tzx`/`.p`/`.o` file onto `/zxdb` and get instant ZXDB entry matches based on SHA-256 hash lookup 🎯
- **Software hashes database** — 32,960-row snapshot of SHA-256 hashes for known ZXDB downloads, with a pipeline script (`update-software-hashes.mjs`) to rebuild/extend it
- **Local ZXDB / WoS mirror support** — proxy downloads through the app's own API so self-hosted mirrors work seamlessly; inline previews rendered without leaving the page
- **Magazine reviews** — reviews now shown on magazine issue pages
- **Label detail pages** — full label view with releases, genre breakdown, and year filtering
- **Year filter** on releases/entries
### 🔧 Improved
- Download viewer reworked: grouped by format, inline previews, human-readable sizes
- Local file path resolution corrected for edge-cases
- Silently skip `/denied/` and other non-hosted prefixes during hash imports
---
## ✅ v0.2.0 — ZXDB Explorer *(December 2025 January 2026)*
The big one. A full cross-linked browser for the ZXDB software database, server-rendered for fast first paint, with deep links everywhere.
### ✨ New
- **ZXDB integration** — MySQL via `mysql2` + Drizzle ORM; Zod-validated env (`ZXDB_URL`)
- **Entries browser** — search, paginate, filter; deep-links to individual entry pages
- **Entry detail pages** — aliases, web references, relations, tags, ports, scores, origins, facets 🗂️
- **Releases browser** — filterable by machine type, genre, label, year; download links
- **Labels browser + detail** — label pages with linked releases
- **Genres, Languages, Machine Types** — category hubs with entry counts
- **Magazines + Issues** — stub magazine browser with issue listing
- **Cross-linked UI** — `EntryLink` component used everywhere; Next `Link` for prefetching
- **SSR + ISR** — index pages server-render initial data; `revalidate = 3600` on non-search pages for fast repeat visits
- **Multi-select machine type filter** with chip toggles; favouring Next hardware by default
- **Shared explorer components** — `ExplorerLayout`, `FilterSidebar`, `FilterSection`, `MultiSelectChips`, `Pagination` 🧩
- **Breadcrumbs** decoupled from search input
- **Landing page** for `/zxdb` with hub links and hero
- **Deploy helper** script (`bin/deploy.sh`)
- **OG images** for register pages (happy new year from Codex 🎉)
### 🔧 Improved
- ZXDB pagination counters fixed
- Facets filter aliasing corrected
- Case-insensitive search improvements
- Graceful handling of missing `releases` / `downloads` schema tables
- Homepage hero updated
- Registers sidebar refactored to share explorer components
### 🏗️ Infrastructure
- Zod env validation for all ZXDB config
- `information_schema.tables` check before querying optional tables
- API routes under `/api/zxdb/*` with Zod input validation, Node runtime
- ZXDB setup guide at `docs/ZXDB.md`
---
## ✅ v0.1.0 — Registers Explorer *(October November 2025)*
The origin story. Started from a Create Next App scaffold with a GPT-5 assist, then heavily hand-crafted into something actually useful for exploring Spectrum Next hardware registers.
### ✨ New
- **Register browser** — loads and parses `data/nextreg.txt`; real-time search/filter with results at a glance
- **Register detail pages** — per-mode bitfield views (read/write/common), notes, and source modal
- **Source viewer** — inline modal showing the raw `nextreg.txt` source for any register
- **Wikilink support** — links parsed from register definitions and rendered as external refs
- **Multi-parser architecture** — `reg_default.ts` for standard registers; `reg_f0.ts` for the exotic `0xF0` register; easy to extend 🔌
- **Multi-line footnote support** — parser handles footnotes that span multiple lines
- **Deep-linkable search** — `?q=` query param synced to the search box so searches can be bookmarked/shared 🔗
- **Dark mode** — cookie-based theme set server-side to eliminate flash-of-wrong-theme ☀️🌙
- **Bootswatch Pulse theme** — purple primary; react-bootstrap throughout
### 🔧 Improved
- iOS bitfield fix — prevent Safari turning hex values into tappable phone numbers 📱
- Parser on-demand (lazy load, not at startup)
- Robust case-insensitive search
- Linting and dead code removed; hallucination CSS cleaned up
- Dokku build pipeline stabilised (pnpm store-dir pinned)
- Next.js security bump (Dec 2025)
### 🏗️ Infrastructure
- Project self-documents via `CLAUDE.md` / `AGENT.md`
- Live `nextreg.txt` used (not bundled snapshot)
- Dev runner fixed for local development

View File

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

View File

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

View File

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

View File

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

View File

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

286
docs/todo.md Normal file
View File

@@ -0,0 +1,286 @@
# 📋 Next Explorer — Code Review & TODO
> Full codebase review performed 2026-03-04. Findings grouped by priority and area.
---
## 🔴 Critical
### 🐛 Bug: `FileViewer` uses `useState` as `useEffect`
**File:** `src/components/FileViewer.tsx:23`
`useState(() => { ... })` is being abused as a side-effect initializer — it calls `fetch()` inside `useState`'s initializer function. This works by accident on first render but violates React rules:
- The initializer can run multiple times in Strict Mode (double-invocation).
- It never re-runs if `url` or `title` props change.
- Side effects in `useState` initializers are explicitly discouraged by React.
**Fix:** Replace with `useEffect` with proper dependency array on `[url, isText]`.
### 🐛 Bug: Register service caching is commented out
**File:** `src/services/register.service.ts:14-18`
The `if (registers.length === 0)` guard is commented out, so the file is re-read and re-parsed on every call to `getRegisters()`. This means every register page load (including the OG image generator and `generateMetadata`) re-parses the entire `nextreg.txt` file. The `[hex]/page.tsx` calls `getRegisters()` twice (once in `generateMetadata`, once in the page function), reading the file from disk twice per request.
**Fix:** Uncomment the caching guard, or better yet, wrap with React `cache()` for request-level deduplication.
### 🔒 Security: `middleware.js` is untyped JavaScript
**File:** `src/middleware.js`
The only `.js` file in the project. It logs every request path to stdout, including potentially sensitive paths. In production this creates noise and potential log injection vectors.
**Fix:** Convert to TypeScript (`.ts`). Consider restricting logging to development only, or removing it entirely since Next.js has built-in request logging.
---
## 🟠 High Priority
### 🧱 Architecture: Duplicated type definitions across files
`Paged<T>`, `Item`, `SearchScope`, `EntryFacets`, and similar types are independently re-declared in:
- `src/hooks/useSearchFetch.ts`
- `src/app/zxdb/entries/EntriesExplorer.tsx`
- `src/app/zxdb/releases/ReleasesExplorer.tsx`
- `src/app/zxdb/labels/LabelsSearch.tsx`
- `src/app/zxdb/genres/GenresSearch.tsx`
And the `EntryDetailData` type in `EntryDetail.tsx` is a near-duplicate of `EntryDetail` in `src/server/repo/zxdb.ts`.
**Fix:** Extract shared types to `src/types/zxdb.ts` and import everywhere.
### 🧱 Architecture: Duplicated `parseMachineIds` / `parseIdList` helpers
The same ID-parsing logic appears in:
- `src/app/zxdb/entries/page.tsx`
- `src/app/zxdb/entries/EntriesExplorer.tsx`
- `src/app/zxdb/releases/page.tsx`
- `src/app/zxdb/releases/ReleasesExplorer.tsx`
- `src/app/api/zxdb/search/route.ts`
**Fix:** Extract to a shared utility (e.g., `src/utils/params.ts`).
### 🧱 Architecture: Duplicated `buildRegisterSummary` logic
`[hex]/page.tsx` and `[hex]/opengraph-image.tsx` each have their own version of register-summary-building logic (one returns a string, one returns lines). The `isInfoLine` filter is duplicated.
**Fix:** Extract to a shared utility in `src/utils/register_helpers.ts`.
### ⚡ Performance: Repository file is 800+ lines with no code splitting
**File:** `src/server/repo/zxdb.ts` (31,000+ tokens)
This monolithic file contains all DB queries. It's hard to navigate and cannot benefit from tree-shaking at the module level.
**Fix:** Split into per-domain files: `repo/entries.ts`, `repo/labels.ts`, `repo/releases.ts`, `repo/magazines.ts`, `repo/lookups.ts`, etc.
### ⚡ Performance: `releases/page.tsx` uses `JSON.parse(JSON.stringify(...))` for serialization
**File:** `src/app/zxdb/releases/page.tsx:51-63`
Uses `JSON.parse(JSON.stringify(initial))` to strip non-serializable values. This is a known workaround for Drizzle decimal types. However, it's applied 7 times in the same function.
**Fix:** Create a `serialize()` helper, or configure Drizzle's `decimal` columns to return strings/numbers natively.
### 🎨 UI Consistency: Mixed raw HTML and react-bootstrap components
Per `CLAUDE.md`, the project should always use react-bootstrap components. Several pages use raw HTML instead:
| File | Issue |
|------|-------|
| `LabelsSearch.tsx` | Raw `<table>`, `<input>`, `<button>`, `<form>` instead of `Table`, `Form.*`, `Button` |
| `LabelDetail.tsx` | Raw `<table>`, `<input>`, `<button>`, nav tabs using raw `<ul>/<li>/<button>` |
| `GenresSearch.tsx` | Raw `<table>`, `<input>`, `<button>` |
| `MachineTypesSearch.tsx` | Likely same pattern |
| `LanguagesSearch.tsx` | Likely same pattern |
| `ReleaseDetail.tsx` | Raw `<table>` throughout instead of `<Table>` |
| `zxdb/page.tsx` | Raw `<input>`, `<select>`, `<button>` in the search form |
| `magazines/page.tsx` | Raw `<table>`, local `Pagination` component instead of shared one |
| `magazines/[id]/page.tsx` | Raw `<table>` |
| `issues/[id]/page.tsx` | Raw `<table>` |
| `page.tsx` (home) | Raw `<div className="card">` instead of `<Card>` |
| `TapeIdentifier.tsx` | Raw `<table>` for hash display |
**Fix:** Systematically replace with react-bootstrap equivalents to match `EntriesExplorer.tsx` and `RegisterBrowser.tsx` patterns.
### 🎨 UI: Magazines page has its own inline `Pagination` component
**File:** `src/app/zxdb/magazines/page.tsx:89-117`
Defines a local `Pagination` function instead of using the shared `src/components/explorer/Pagination.tsx`.
**Fix:** Use the shared `Pagination` component.
---
## 🟡 Medium Priority
### ⚛️ React: `useSearchFetch` `onExtra` callback may cause infinite loops
**File:** `src/hooks/useSearchFetch.ts:75`
The `fetch_` callback depends on `[endpoint, onExtra]`. If the caller doesn't memoize `onExtra`, this dependency changes every render, creating a new `fetch_` reference, which could cascade into effect re-runs.
The `EntriesExplorer.tsx` correctly `useCallback`-wraps `handleExtra`, but this is a fragile contract.
**Fix:** Store `onExtra` in a ref instead of including it in the dependency array, or document the requirement clearly.
### ⚛️ React: Missing `notFound()` call in entry detail page
**File:** `src/app/zxdb/entries/[id]/page.tsx:13-15`
When `getEntryById` returns null, the page still renders with status 200 — the client component shows an "alert-warning" div. This means:
- Search engines index a 200 page with "Not found" content.
- No proper 404 HTTP status.
**Fix:** Call `notFound()` in the server component when `data` is null, like `magazines/[id]/page.tsx` does.
### ⚛️ React: Same issue for release detail page
**File:** `src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx`
Same pattern — no `notFound()` when `data` is null.
### ⚛️ React: `RegisterBrowser` disables exhaustive-deps without justification
**File:** `src/app/registers/RegisterBrowser.tsx:90-91`
The `eslint-disable-next-line react-hooks/exhaustive-deps` on the `searchParams` sync effect excludes `searchTerm` from deps, which could lead to stale closures if `searchParams` changes while `searchTerm` is mid-update.
### 📦 Metadata: Entry/release/label detail pages lack dynamic metadata
**Files:**
- `src/app/zxdb/entries/[id]/page.tsx` — static `metadata = { title: "ZXDB Entry" }`
- `src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx` — static `metadata = { title: "ZXDB Release" }`
- `src/app/zxdb/labels/[id]/page.tsx` — static `metadata = { title: "ZXDB Label" }`
These should use `generateMetadata` to include the entry/release/label title for SEO and social sharing, similar to how `registers/[hex]/page.tsx` does it.
### 🧱 Architecture: `ThemeDropdown` hardcodes cookie domain
**File:** `src/components/ThemeDropdown.tsx:16`
```typescript
document.cookie = `${name}=...; Domain=specnext.dev`;
```
This hardcoded domain means the theme cookie won't work on `localhost` or any non-specnext.dev domain during development.
**Fix:** Remove the `Domain=` attribute (let it default to current host), or conditionally set it based on environment.
### ⚡ Performance: No `loading.tsx` or `Suspense` boundaries
None of the route segments define `loading.tsx` files. For `force-dynamic` pages (entries, releases, labels, genres, languages, machinetypes), users see a blank page or frozen UI while the server fetches from MySQL.
**Fix:** Add `loading.tsx` with skeleton/spinner states for ZXDB routes.
### ⚡ Performance: `opengraph-image.tsx` calls `getRegisters()` for every OG image
**File:** `src/app/registers/[hex]/opengraph-image.tsx:132-136`
Loads ALL registers from disk just to find one. With the caching fix above this becomes a non-issue, but currently it's reading and parsing the full file for each image request.
### 🔒 Security: Download API path traversal protection should normalize before joining
**File:** `src/app/api/zxdb/download/route.ts:27-28`
The current protection `path.normalize(path.join(baseDir, filePath))` is correct, but the check should also reject paths containing `..` before `join` for defense-in-depth.
### 📝 DX: `parseNextReg()` is async but does no async work
**File:** `src/utils/register_parser.ts:49`
`parseNextReg()` is declared `async` and returns `Promise<Register[]>`, but the function body is entirely synchronous. This forces callers to `await` unnecessarily.
**Fix:** Remove `async` and return `Register[]` directly.
---
## 🟢 Low Priority
### 🎨 UI: `Navbar` uses `Link` with `className="nav-link"` instead of `Nav.Link`
**File:** `src/components/Navbar.tsx:14-16`
For consistency with react-bootstrap patterns, use `<Nav.Link as={Link} href="...">` instead of raw `<Link className="nav-link">`.
### 🎨 UI: Home page uses `bi bi-*` CSS classes instead of react-bootstrap-icons
**File:** `src/app/page.tsx:12,29`
Uses `<span className="bi bi-collection">` instead of the `react-bootstrap-icons` package that's used elsewhere.
### 🎨 UI: `TapeIdentifier` uses `bi bi-*` CSS classes
**File:** `src/app/zxdb/TapeIdentifier.tsx`
Same issue — uses Bootstrap icon CSS classes instead of `react-bootstrap-icons` components.
### 🎨 UI: Inconsistent "not found" patterns
Some pages use `notFound()` (magazines, issues), others render inline alerts (entries, releases, labels). This creates inconsistent UX.
### ⚛️ React: `buildRegisterSummaryLines` in OG image could use better key strategy
**File:** `src/app/registers/[hex]/opengraph-image.tsx:174,188`
Uses `key={line}` which will produce duplicate keys if two lines have identical text.
### 📝 DX: Commented-out code in register parsers
**Files:**
- `src/utils/register_parsers/reg_default.ts:13,106,122-126` — commented `footnoteTarget` variable and `valueMatch` block
- `src/services/register.service.ts:14,18` — commented caching guard
**Fix:** Remove dead code or convert to tracked TODOs.
### 📝 DX: `app/page.module.css` exists but is never imported
Check if this file has any content; if empty/unused, remove it.
### 🧪 Testing: Zero test coverage
No test files exist (`*.test.ts`, `*.spec.ts`, `__tests__/`). Key areas that would benefit:
1. Register parser (`parseNextReg`, `parseDescriptionDefault`, `parseDescriptionF0`) — complex parsing logic with edge cases.
2. API route input validation (Zod schemas).
3. `useSearchFetch` hook behavior (cancellation, race conditions).
4. `computeMd5` correctness.
5. Component rendering for entry detail, release detail.
### ♿ Accessibility: Search inputs lack proper labeling
Several search inputs use `placeholder` as the only label (no associated `<label>` or `aria-label`):
- `zxdb/page.tsx` search form
- Various filter sidebars
### ♿ Accessibility: Tables lack `<caption>` elements
Data tables throughout the ZXDB explorer have no `<caption>`, making it harder for screen readers to understand table purpose.
### ⚡ Performance: `EntriesExplorer` and `ReleasesExplorer` have very similar structures
Both follow the same pattern: sidebar with filters, table results, pagination. They share about 60% structural similarity. Consider extracting a shared `SearchExplorer` wrapper that accepts column definitions and filter config.
### 📦 Bundle: `import * as Icon from 'react-bootstrap-icons'`
**File:** `src/app/registers/RegisterDetail.tsx:8`, `src/components/ThemeDropdown.tsx:4`
Importing the entire icon library. While tree-shaking should handle this, named imports are safer and make dependencies explicit.
**Fix:** `import { Wikipedia, Link45deg, CodeSlash } from 'react-bootstrap-icons'`
### 📝 DX: `CLAUDE.md` structure tree is outdated
The project tree in `CLAUDE.md` doesn't reflect the current file structure (missing `hooks/`, `components/explorer/`, ZXDB pages, API routes, `server/`, etc.).
---
## 🗺️ Feature Ideas (non-bugs)
- **Search debouncing** — `RegisterBrowser` updates the URL on every keystroke. Consider debouncing the URL update (keep instant local filtering).
- **Entry detail OG images** — Register pages have OG images; ZXDB entry pages do not.
- **Keyboard navigation** — Add keyboard shortcuts for pagination (left/right arrows).
- **Back-to-top button** — Long entry detail pages would benefit.
- **Error boundaries** — No `error.tsx` files exist for graceful error recovery in route segments.
- **Rate limiting** — API routes have no rate limiting for the search endpoints.

View File

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

View File

@@ -10,6 +10,8 @@
"deploy": "bin/deploy.sh", "deploy": "bin/deploy.sh",
"deploy:branch": "bin/deploy.sh", "deploy:branch": "bin/deploy.sh",
"setup:zxdb-local": "bin/setup-zxdb-local.sh", "setup:zxdb-local": "bin/setup-zxdb-local.sh",
"update:hashes": "node bin/update-software-hashes.mjs",
"export:hashes": "node bin/update-software-hashes.mjs --export-only",
"deploy-prod": "git push --set-upstream explorer.specnext.dev deploy", "deploy-prod": "git push --set-upstream explorer.specnext.dev deploy",
"deploy-test": "git push --set-upstream test.explorer.specnext.dev test" "deploy-test": "git push --set-upstream test.explorer.specnext.dev test"
}, },

View File

@@ -1,4 +1,4 @@
import { listAvailabletypes } from "@/server/repo/zxdb"; import { listAvailabletypes } from "@/server/repo";
export async function GET() { export async function GET() {
const items = await listAvailabletypes(); const items = await listAvailabletypes();

View File

@@ -1,4 +1,4 @@
import { listCasetypes } from "@/server/repo/zxdb"; import { listCasetypes } from "@/server/repo";
export async function GET() { export async function GET() {
const items = await listCasetypes(); const items = await listCasetypes();

View File

@@ -1,4 +1,4 @@
import { listCurrencies } from "@/server/repo/zxdb"; import { listCurrencies } from "@/server/repo";
export async function GET() { export async function GET() {
const items = await listCurrencies(); const items = await listCurrencies();

View File

@@ -0,0 +1,76 @@
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 });
}
// Defense-in-depth: reject obvious traversal before joining
if (filePath.includes("..")) {
return new NextResponse("Forbidden", { status: 403 });
}
// Security: Ensure path doesn't escape baseDir
const absolutePath = path.normalize(path.join(baseDir, filePath));
if (!absolutePath.startsWith(path.normalize(baseDir))) {
return new NextResponse("Forbidden", { status: 403 });
}
if (!fs.existsSync(absolutePath)) {
return new NextResponse("File not found", { status: 404 });
}
const stat = fs.statSync(absolutePath);
if (!stat.isFile()) {
return new NextResponse("Not a file", { status: 400 });
}
const fileBuffer = fs.readFileSync(absolutePath);
const fileName = path.basename(absolutePath);
const ext = path.extname(fileName).toLowerCase();
// Determine Content-Type
let contentType = "application/octet-stream";
if (ext === ".txt" || ext === ".nfo") {
contentType = "text/plain; charset=utf-8";
} else if (ext === ".png") {
contentType = "image/png";
} else if (ext === ".jpg" || ext === ".jpeg") {
contentType = "image/jpeg";
} else if (ext === ".gif") {
contentType = "image/gif";
} else if (ext === ".pdf") {
contentType = "application/pdf";
}
const isView = searchParams.get("view") === "1";
const disposition = isView ? "inline" : "attachment";
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `${disposition}; filename="${fileName}"`,
"Content-Length": stat.size.toString(),
},
});
}
export const runtime = "nodejs";

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { getEntryById } from "@/server/repo/zxdb"; import { getEntryById } from "@/server/repo";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() }); const paramsSchema = z.object({ id: z.coerce.number().int().positive() });

View File

@@ -1,4 +1,4 @@
import { listFiletypes } from "@/server/repo/zxdb"; import { listFiletypes } from "@/server/repo";
export async function GET() { export async function GET() {
const items = await listFiletypes(); const items = await listFiletypes();

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { entriesByGenre } from "@/server/repo/zxdb"; import { entriesByGenre } from "@/server/repo";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() }); const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
const querySchema = z.object({ const querySchema = z.object({

View File

@@ -1,4 +1,4 @@
import { listGenres } from "@/server/repo/zxdb"; import { listGenres } from "@/server/repo";
export async function GET() { export async function GET() {
const data = await listGenres(); const data = await listGenres();

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb"; import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() }); const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
const querySchema = z.object({ const querySchema = z.object({

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { searchLabels } from "@/server/repo/zxdb"; import { searchLabels } from "@/server/repo";
const querySchema = z.object({ const querySchema = z.object({
q: z.string().optional(), q: z.string().optional(),

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { entriesByLanguage } from "@/server/repo/zxdb"; import { entriesByLanguage } from "@/server/repo";
const paramsSchema = z.object({ id: z.string().trim().length(2) }); const paramsSchema = z.object({ id: z.string().trim().length(2) });
const querySchema = z.object({ const querySchema = z.object({

View File

@@ -1,4 +1,4 @@
import { listLanguages } from "@/server/repo/zxdb"; import { listLanguages } from "@/server/repo";
export async function GET() { export async function GET() {
const data = await listLanguages(); const data = await listLanguages();

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { entriesByMachinetype } from "@/server/repo/zxdb"; import { entriesByMachinetype } from "@/server/repo";
const paramsSchema = z.object({ id: z.coerce.number().int().positive() }); const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
const querySchema = z.object({ const querySchema = z.object({

View File

@@ -1,4 +1,4 @@
import { listMachinetypes } from "@/server/repo/zxdb"; import { listMachinetypes } from "@/server/repo";
export async function GET() { export async function GET() {
const data = await listMachinetypes(); const data = await listMachinetypes();

View File

@@ -1,6 +1,7 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { searchReleases } from "@/server/repo/zxdb"; import { searchReleases } from "@/server/repo";
import { parseIdList } from "@/utils/params";
const querySchema = z.object({ const querySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@@ -17,15 +18,6 @@ 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({

View File

@@ -1,4 +1,4 @@
import { listRoletypes } from "@/server/repo/zxdb"; import { listRoletypes } from "@/server/repo";
export async function GET() { export async function GET() {
const items = await listRoletypes(); const items = await listRoletypes();

View File

@@ -1,4 +1,4 @@
import { listSchemetypes } from "@/server/repo/zxdb"; import { listSchemetypes } from "@/server/repo";
export async function GET() { export async function GET() {
const items = await listSchemetypes(); const items = await listSchemetypes();

View File

@@ -1,6 +1,7 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { searchEntries, getEntryFacets } from "@/server/repo/zxdb"; import { searchEntries, getEntryFacets } from "@/server/repo";
import { parseIdList } from "@/utils/params";
const querySchema = z.object({ const querySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@@ -13,20 +14,12 @@ const querySchema = z.object({
.length(2, "languageId must be a 2-char code") .length(2, "languageId must be a 2-char code")
.optional(), .optional(),
machinetypeId: z.string().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(), 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({
@@ -36,6 +29,7 @@ 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, scope: searchParams.get("scope") ?? undefined,
facets: searchParams.get("facets") ?? undefined, facets: searchParams.get("facets") ?? undefined,

View File

@@ -1,4 +1,4 @@
import { listSourcetypes } from "@/server/repo/zxdb"; import { listSourcetypes } from "@/server/repo";
export async function GET() { export async function GET() {
const items = await listSourcetypes(); const items = await listSourcetypes();

View File

@@ -1,163 +0,0 @@
/*.page {*/
/* --gray-rgb: 0, 0, 0;*/
/* --gray-alpha-200: rgba(var(--gray-rgb), 0.08);*/
/* --gray-alpha-100: rgba(var(--gray-rgb), 0.05);*/
/* --button-primary-hover: #383838;*/
/* --button-secondary-hover: #f2f2f2;*/
/* display: grid;*/
/* grid-template-rows: 20px 1fr 20px;*/
/* align-items: center;*/
/* justify-items: center;*/
/* min-height: 100svh;*/
/* padding: 80px;*/
/* gap: 64px;*/
/*}*/
/*@media (prefers-color-scheme: dark) {*/
/* .page {*/
/* --gray-rgb: 255, 255, 255;*/
/* --gray-alpha-200: rgba(var(--gray-rgb), 0.145);*/
/* --gray-alpha-100: rgba(var(--gray-rgb), 0.06);*/
/* --button-primary-hover: #ccc;*/
/* --button-secondary-hover: #1a1a1a;*/
/* }*/
/*}*/
/*.main {*/
/* display: flex;*/
/* flex-direction: column;*/
/* gap: 32px;*/
/* grid-row-start: 2;*/
/*}*/
/*.main ol {*/
/* padding-left: 0;*/
/* margin: 0;*/
/* font-size: 14px;*/
/* line-height: 24px;*/
/* letter-spacing: -0.01em;*/
/* list-style-position: inside;*/
/*}*/
/*.main li:not(:last-of-type) {*/
/* margin-bottom: 8px;*/
/*}*/
/*.main code {*/
/* font-family: inherit;*/
/* background: var(--gray-alpha-100);*/
/* padding: 2px 4px;*/
/* border-radius: 4px;*/
/* font-weight: 600;*/
/*}*/
/*.ctas {*/
/* display: flex;*/
/* gap: 16px;*/
/*}*/
/*.ctas a {*/
/* appearance: none;*/
/* border-radius: 128px;*/
/* height: 48px;*/
/* padding: 0 20px;*/
/* border: 1px solid transparent;*/
/* transition:*/
/* background 0.2s,*/
/* color 0.2s,*/
/* border-color 0.2s;*/
/* cursor: pointer;*/
/* display: flex;*/
/* align-items: center;*/
/* justify-content: center;*/
/* font-size: 16px;*/
/* line-height: 20px;*/
/* font-weight: 500;*/
/*}*/
/*a.primary {*/
/* gap: 8px;*/
/*}*/
/*a.secondary {*/
/* border-color: var(--gray-alpha-200);*/
/* min-width: 158px;*/
/*}*/
/*.footer {*/
/* grid-row-start: 3;*/
/* display: flex;*/
/* gap: 24px;*/
/*}*/
/*.footer a {*/
/* display: flex;*/
/* align-items: center;*/
/* gap: 8px;*/
/*}*/
/*.footer img {*/
/* flex-shrink: 0;*/
/*}*/
/*!* Enable hover only on non-touch devices *!*/
/*@media (hover: hover) and (pointer: fine) {*/
/* a.primary:hover {*/
/* background: var(--button-primary-hover);*/
/* border-color: transparent;*/
/* }*/
/* a.secondary:hover {*/
/* background: var(--button-secondary-hover);*/
/* border-color: transparent;*/
/* }*/
/* .footer a:hover {*/
/* text-decoration: underline;*/
/* text-underline-offset: 4px;*/
/* }*/
/*}*/
/*@media (max-width: 600px) {*/
/* .page {*/
/* padding: 32px;*/
/* padding-bottom: 80px;*/
/* }*/
/* .main {*/
/* align-items: center;*/
/* }*/
/* .main ol {*/
/* text-align: center;*/
/* }*/
/* .ctas {*/
/* flex-direction: column;*/
/* }*/
/* .ctas a {*/
/* font-size: 14px;*/
/* height: 40px;*/
/* padding: 0 16px;*/
/* }*/
/* a.secondary {*/
/* min-width: auto;*/
/* }*/
/* .footer {*/
/* flex-wrap: wrap;*/
/* align-items: center;*/
/* justify-content: center;*/
/* }*/
/*}*/
/*@media (prefers-color-scheme: dark) {*/
/* .logo {*/
/* filter: invert();*/
/* }*/
/*}*/

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Collection, Cpu } from "react-bootstrap-icons";
export default function Home() { export default function Home() {
return ( return (
@@ -8,7 +9,7 @@ export default function Home() {
<div className="card h-100 shadow-sm"> <div className="card h-100 shadow-sm">
<div className="card-body d-flex flex-column gap-3"> <div className="card-body d-flex flex-column gap-3">
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">
<span className="bi bi-collection" style={{ fontSize: 40 }} aria-hidden /> <Collection size={40} aria-hidden />
<div> <div>
<h1 className="h3 mb-1">ZXDB Explorer</h1> <h1 className="h3 mb-1">ZXDB Explorer</h1>
<p className="text-secondary mb-0">Search entries, releases, magazines, and labels.</p> <p className="text-secondary mb-0">Search entries, releases, magazines, and labels.</p>
@@ -26,7 +27,7 @@ export default function Home() {
<div className="card h-100 shadow-sm"> <div className="card h-100 shadow-sm">
<div className="card-body d-flex flex-column gap-3"> <div className="card-body d-flex flex-column gap-3">
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">
<span className="bi bi-cpu" style={{ fontSize: 40 }} aria-hidden /> <Cpu size={40} aria-hidden />
<div> <div>
<h2 className="h3 mb-1">NextReg Explorer</h2> <h2 className="h3 mb-1">NextReg Explorer</h2>
<p className="text-secondary mb-0">Browse Spectrum Next registers and bitfields.</p> <p className="text-secondary mb-0">Browse Spectrum Next registers and bitfields.</p>

View File

@@ -5,7 +5,7 @@ import { Col, Card, Tabs, Tab, Button, Modal } from 'react-bootstrap';
import { Register } from '@/utils/register_parser'; import { Register } from '@/utils/register_parser';
import { renderAccess } from './RegisterBrowser'; import { renderAccess } from './RegisterBrowser';
import Link from "next/link"; import Link from "next/link";
import * as Icon from 'react-bootstrap-icons'; import { Wikipedia, Link45deg, CodeSlash } from 'react-bootstrap-icons';
/** /**
* A client-side component that displays the details of a single register. * A client-side component that displays the details of a single register.
@@ -24,18 +24,18 @@ export default function RegisterDetail({
<Card> <Card>
<Card.Header> <Card.Header>
<code>{register.hex_address}</code> ( {register.dec_address} ) &nbsp; <code>{register.hex_address}</code> ( {register.dec_address} ) &nbsp;
<strong>{register.name}</strong> {register.issue_4_only && <span className="badge bg-danger">Issue 4 Only</span>} <strong>{register.name}</strong> {register.issue_4_only && <span className="badge bg-danger">Issues 4 &amp; 5 Only</span>}
<div className="float-end small text-muted"> <div className="float-end small text-muted">
<Link href={register.wiki_link} className="text-decoration-none btn btn-sm btn-primary" title="Open wiki"> <Link href={register.wiki_link} className="text-decoration-none btn btn-sm btn-primary" title="Open wiki">
<Icon.Wikipedia /> <Wikipedia />
</Link> </Link>
&nbsp; &nbsp;
<Link href={`/registers/${register.hex_address}`} className="text-decoration-none btn btn-sm btn-primary" title="Permalink"> <Link href={`/registers/${register.hex_address}`} className="text-decoration-none btn btn-sm btn-primary" title="Permalink">
<Icon.Link45deg /> <Link45deg />
</Link> </Link>
&nbsp; &nbsp;
<Button variant="primary" size="sm" className="text-decoration-none" onClick={() => setShowSource(true)} title="View source"> <Button variant="primary" size="sm" className="text-decoration-none" onClick={() => setShowSource(true)} title="View source">
<Icon.CodeSlash /> <CodeSlash />
</Button> </Button>
</div> </div>
</Card.Header> </Card.Header>

View File

@@ -1,5 +1,6 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getRegisters } from '@/services/register.service'; import { getRegisters } from '@/services/register.service';
import { buildRegisterSummaryLines } from '@/utils/register_helpers';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -10,70 +11,6 @@ export const size = {
export const contentType = 'image/png'; export const contentType = 'image/png';
const buildRegisterSummaryLines = (register: { description: string; text: string; modes: { text: string }[] }) => {
const isInfoLine = (line: string) =>
line.length > 0 &&
!line.startsWith('//') &&
!line.startsWith('(R') &&
!line.startsWith('(W') &&
!line.startsWith('(R/W') &&
!line.startsWith('*') &&
!/^bits?\s+\d/i.test(line);
const normalizeLines = (raw: string) => {
const lines: string[] = [];
const rawLines = raw.split('\n');
for (const rawLine of rawLines) {
const trimmed = rawLine.trim();
if (!trimmed) {
if (lines.length > 0 && lines[lines.length - 1] !== '') {
lines.push('');
}
continue;
}
if (isInfoLine(trimmed)) {
lines.push(trimmed);
}
}
return lines;
};
const textLines = normalizeLines(register.text);
const modeLines = register.modes.flatMap(mode => normalizeLines(mode.text));
const descriptionLines = normalizeLines(register.description);
const combined: string[] = [];
const appendBlock = (block: string[]) => {
if (block.length === 0) return;
if (combined.length > 0 && combined[combined.length - 1] !== '') {
combined.push('');
}
combined.push(...block);
};
appendBlock(textLines);
appendBlock(modeLines);
appendBlock(descriptionLines);
const deduped: string[] = [];
const seen = new Set<string>();
for (const line of combined) {
if (!line) {
if (deduped.length > 0 && deduped[deduped.length - 1] !== '') {
deduped.push('');
}
continue;
}
if (seen.has(line)) {
continue;
}
seen.add(line);
deduped.push(line);
}
return deduped.length > 0 ? deduped : ['Spectrum Next register details and bit-level behavior.'];
};
const splitLongWord = (word: string, maxLineLength: number) => { const splitLongWord = (word: string, maxLineLength: number) => {
if (word.length <= maxLineLength) return [word]; if (word.length <= maxLineLength) return [word];
const chunks: string[] = []; const chunks: string[] = [];
@@ -171,8 +108,8 @@ export default async function Image({ params }: { params: Promise<{ hex: string
lineHeight: 1.05, lineHeight: 1.05,
}} }}
> >
{titleLines.map(line => ( {titleLines.map((line, idx) => (
<div key={line}>{line}</div> <div key={idx}>{line}</div>
))} ))}
</div> </div>
<div <div
@@ -185,8 +122,8 @@ export default async function Image({ params }: { params: Promise<{ hex: string
color: '#f7f1ff', color: '#f7f1ff',
}} }}
> >
{summaryLines.map(line => ( {summaryLines.map((line, idx) => (
<div key={line}>{line}</div> <div key={idx}>{line}</div>
))} ))}
</div> </div>
<div style={{ marginTop: 'auto', fontSize: '26px', color: '#dacbff' }}> <div style={{ marginTop: 'auto', fontSize: '26px', color: '#dacbff' }}>

View File

@@ -5,27 +5,7 @@ import RegisterDetail from '@/app/registers/RegisterDetail';
import {Container, Row} from "react-bootstrap"; import {Container, Row} from "react-bootstrap";
import { getRegisters } from '@/services/register.service'; import { getRegisters } from '@/services/register.service';
import {env} from "@/env"; import {env} from "@/env";
import { buildRegisterSummary } from "@/utils/register_helpers";
const buildRegisterSummary = (register: { description: string; text: string; modes: { text: string }[] }) => {
const trimLine = (line: string) => line.trim();
const isInfoLine = (line: string) =>
line.length > 0 &&
!line.startsWith('//') &&
!line.startsWith('(R') &&
!line.startsWith('(W') &&
!line.startsWith('(R/W') &&
!line.startsWith('*') &&
!/^bits?\s+\d/i.test(line);
const modeLines = register.modes.flatMap(mode => mode.text.split('\n')).map(trimLine).filter(isInfoLine);
const textLines = register.text.split('\n').map(trimLine).filter(isInfoLine);
const descriptionLines = register.description.split('\n').map(trimLine).filter(isInfoLine);
const rawSummary = [...textLines, ...modeLines, ...descriptionLines].join(' ').replace(/\s+/g, ' ').trim();
if (!rawSummary) return 'Spectrum Next register details and bit-level behavior.';
if (rawSummary.length <= 180) return rawSummary;
return `${rawSummary.slice(0, 177).trimEnd()}...`;
};
export async function generateMetadata({ params }: { params: Promise<{ hex: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ hex: string }> }): Promise<Metadata> {
const registers = await getRegisters(); const registers = await getRegisters();

View File

@@ -0,0 +1,235 @@
"use client";
import { useState, useRef, useCallback } from "react";
import Link from "next/link";
import { Card, Table, Alert, Badge, Spinner, Button } from "react-bootstrap";
import { Cassette, CloudArrowUp, PersonFill, TagFill, CpuFill, ArrowRight } from "react-bootstrap-icons";
import { computeMd5 } from "@/utils/md5";
import { identifyTape } from "./actions";
import type { TapeMatch } from "@/server/repo";
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 (
<Card className="border-0 shadow-sm">
<Card.Body>
<Card.Title className="d-flex align-items-center gap-2 mb-3">
<Cassette size={22} aria-hidden />
Tape Identifier
</Card.Title>
{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) => (
<Card key={m.downloadId} className="border mb-3">
<Card.Body>
<div className="d-flex justify-content-between align-items-start mb-2">
<Card.Title as="h6" className="mb-0">
<Link href={`/zxdb/entries/${m.entryId}`} className="text-decoration-none">
{m.entryTitle}
</Link>
</Card.Title>
{m.releaseYear && (
<Badge bg="secondary" className="ms-2">{m.releaseYear}</Badge>
)}
</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><PersonFill className="me-1" aria-hidden />{m.authors.join(", ")}</span>
)}
{m.genre && (
<span><TagFill className="me-1" aria-hidden />{m.genre}</span>
)}
{m.machinetype && (
<span><CpuFill className="me-1" aria-hidden />{m.machinetype}</span>
)}
</div>
)}
<Table size="sm" borderless className="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 <ArrowRight className="ms-1" aria-hidden />
</Link>
</Card.Body>
</Card>
))}
</>
) : (
<p className="text-secondary mb-3">
No matching tape found in ZXDB for <strong>{state.fileName}</strong>.
</p>
)}
<Button variant="outline-primary" size="sm" onClick={reset}>
Identify another tape
</Button>
</Card.Body>
</Card>
);
}
const isProcessing = state.kind === "hashing" || state.kind === "identifying";
return (
<Card className="border-0 shadow-sm">
<Card.Body>
<Card.Title className="d-flex align-items-center gap-2 mb-3">
<Cassette size={22} aria-hidden />
Tape Identifier
</Card.Title>
<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">
<Spinner animation="border" size="sm" variant="primary" className="me-2" />
<span className="text-secondary">
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
</span>
</div>
) : (
<>
<div className="mb-2">
<CloudArrowUp size={32} style={{ opacity: 0.5 }} aria-hidden />
</div>
<p className="mb-1 text-secondary">
Drop a tape file to identify it
</p>
<p className="mb-0 small text-secondary">
{SUPPORTED_EXTS.join(" ")} &mdash; or{" "}
<span className="text-primary" style={{ textDecoration: "underline", cursor: "pointer" }}>
choose file
</span>
</p>
</>
)}
<input
ref={inputRef}
type="file"
accept={SUPPORTED_EXTS.join(",")}
className="d-none"
onChange={handleFileInput}
/>
</div>
{state.kind === "error" && (
<Alert variant="warning" className="mt-3 mb-0 py-2 small">
{state.message}
</Alert>
)}
</Card.Body>
</Card>
);
}

View File

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

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

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

View File

@@ -1,15 +1,22 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Form, InputGroup, Button, Table, Alert, Badge } from "react-bootstrap";
import { Search } from "react-bootstrap-icons";
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 ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout"; import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar"; import FilterSidebar from "@/components/explorer/FilterSidebar";
import FilterSection from "@/components/explorer/FilterSection";
import MultiSelectChips from "@/components/explorer/MultiSelectChips"; import MultiSelectChips from "@/components/explorer/MultiSelectChips";
import Pagination from "@/components/explorer/Pagination";
import useSearchFetch from "@/hooks/useSearchFetch";
import type { PagedResult, EntryFacets, EntrySearchScope } from "@/types/zxdb";
import { preferredMachineIds, parseMachineIds } from "@/utils/params";
const preferredMachineIds = [27, 26, 8, 9]; const PAGE_SIZE = 20;
type Item = { type Item = {
id: number; id: number;
@@ -23,22 +30,6 @@ type Item = {
languageName?: string | null; languageName?: string | null;
}; };
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
type EntryFacets = {
genres: { id: number; name: string; count: number }[];
languages: { id: string; name: string; count: number }[];
machinetypes: { id: number; name: string; count: number }[];
flags: { hasAliases: number; hasOrigins: number };
};
export default function EntriesExplorer({ export default function EntriesExplorer({
initial, initial,
initialGenres, initialGenres,
@@ -47,7 +38,7 @@ export default function EntriesExplorer({
initialFacets, initialFacets,
initialUrlState, initialUrlState,
}: { }: {
initial?: Paged<Item>; initial?: PagedResult<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 }[];
@@ -59,143 +50,113 @@ export default function EntriesExplorer({
languageId: string | ""; languageId: string | "";
machinetypeId: string; machinetypeId: string;
sort: "title" | "id_desc"; sort: "title" | "id_desc";
scope?: SearchScope; scope?: EntrySearchScope;
}; };
}) { }) {
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();
// -- Search state --
const [q, setQ] = useState(initialUrlState?.q ?? ""); const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = 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 [data, setData] = useState<Paged<Item> | null>(initial ?? null);
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []);
const [genreId, setGenreId] = useState<number | "">( const [genreId, setGenreId] = useState<number | "">(
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 [machinetypeIds, setMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.machinetypeId)); const [machinetypeIds, setMachinetypeIds] = useState<number[]>(parseMachineIds(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 [scope, setScope] = useState<EntrySearchScope>(initialUrlState?.scope ?? "title");
const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null); const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null);
const preferredMachineNames = useMemo(() => {
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`); // -- Filter lists --
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
}, [machines]); const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []);
// Capture facets from the API response alongside paged results
const handleExtra = useCallback((json: Record<string, unknown>) => {
if (json.facets) setFacets(json.facets as EntryFacets);
}, []);
// -- Fetch with abort control --
const { data, loading, error, fetch: doFetch, syncData } = useSearchFetch<Item>(
"/api/zxdb/search",
initial ?? null,
handleExtra,
);
// Skip initial fetch when SSR data already matches
const isFirstRender = useRef(true);
const totalPages = useMemo(
() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1),
[data],
);
// -- URL helpers --
const buildParams = useCallback(
(p: number) => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(p));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return params;
},
[appliedQ, genreId, languageId, machinetypeIds, sort, scope],
);
const buildHref = useCallback(
(p: number) => {
const qs = buildParams(p).toString();
return qs ? `${pathname}?${qs}` : pathname;
},
[buildParams, pathname],
);
// -- Derived --
const orderedMachines = useMemo(() => { const orderedMachines = useMemo(() => {
const seen = new Set(preferredMachineIds); const seen = new Set(preferredMachineIds);
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[]; 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)); const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest]; return [...preferred, ...rest];
}, [machines]); }, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20; const machineOptions = useMemo(
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); () => orderedMachines.map((m) => ({ id: m.id, label: m.name })),
const activeFilters = useMemo(() => { [orderedMachines],
const chips: string[] = []; );
if (appliedQ) chips.push(`q: ${appliedQ}`);
if (genreId !== "") {
const name = genres.find((g) => g.id === Number(genreId))?.name ?? `#${genreId}`;
chips.push(`genre: ${name}`);
}
if (languageId !== "") {
const name = languages.find((l) => l.id === languageId)?.name ?? languageId;
chips.push(`lang: ${name}`);
}
if (machinetypeIds.length > 0) {
const names = machinetypeIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`);
chips.push(`machine: ${names.join(", ")}`);
}
if (scope === "title_aliases") chips.push("scope: titles + aliases");
if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins");
return chips;
}, [appliedQ, genreId, languageId, machinetypeIds, scope, genres, languages, machines]);
function updateUrl(nextPage = page) { const hasNonDefaultMachineFilter = machinetypeIds.join(",") !== preferredMachineIds.join(",") ||
const params = new URLSearchParams(); machinetypeIds.length !== machines.length;
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(nextPage));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname);
}
async function fetchData(query: string, p: number, withFacets: boolean) { // -- Fetch + URL sync on filter/page changes --
setLoading(true);
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("page", String(p));
params.set("pageSize", String(pageSize));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
if (withFacets) params.set("facets", "true");
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = await res.json();
setData(json);
if (withFacets && json.facets) {
setFacets(json.facets as EntryFacets);
}
} catch (e) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}
// Sync from SSR payload on navigation
useEffect(() => { useEffect(() => {
if (initial) { if (isFirstRender.current) {
setData(initial); isFirstRender.current = false;
setPage(initial.page); router.replace(buildHref(page), { scroll: false });
}
}, [initial]);
// Client fetch when filters/paging/sort change; also keep URL in sync
useEffect(() => {
// Avoid extra fetch if SSR already matches this exact default state
const initialPage = initial?.page ?? 1;
if (
initial &&
page === initialPage &&
(initialUrlState?.q ?? "") === appliedQ &&
(initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) &&
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
parseMachineIds(initialUrlState?.machinetypeId).join(",") === machinetypeIds.join(",") &&
sort === (initialUrlState?.sort ?? "id_desc") &&
(initialUrlState?.scope ?? "title") === scope
) {
updateUrl(page);
return; return;
} }
updateUrl(page);
fetchData(appliedQ, page, true); router.replace(buildHref(page), { scroll: false });
const params = buildParams(page);
params.set("pageSize", String(PAGE_SIZE));
params.set("facets", "true");
doFetch(params);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]); }, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]);
// Load filter lists on mount only if not provided by server // Sync SSR data when navigating (browser back/forward)
useEffect(() => {
if (initial) syncData(initial);
}, [initial, syncData]);
// Load filter lists on mount if not provided by server
useEffect(() => { useEffect(() => {
if (initialGenres && initialLanguages && initialMachines) return; if (initialGenres && initialLanguages && initialMachines) return;
async function loadLists() { async function loadLists() {
@@ -208,7 +169,7 @@ export default function EntriesExplorer({
setGenres(g.items ?? []); setGenres(g.items ?? []);
setLanguages(l.items ?? []); setLanguages(l.items ?? []);
setMachines(m.items ?? []); setMachines(m.items ?? []);
} catch {} } catch { /* filter lists are non-critical */ }
} }
loadLists(); loadLists();
}, [initialGenres, initialLanguages, initialMachines]); }, [initialGenres, initialLanguages, initialMachines]);
@@ -219,6 +180,11 @@ export default function EntriesExplorer({
setPage(1); setPage(1);
} }
function searchAllMachines() {
setMachinetypeIds(machineOptions.map((m) => m.id));
setPage(1);
}
function resetFilters() { function resetFilters() {
setQ(""); setQ("");
setAppliedQ(""); setAppliedQ("");
@@ -230,30 +196,6 @@ export default function EntriesExplorer({
setPage(1); setPage(1);
} }
const prevHref = useMemo(() => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1)));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
const nextHref = useMemo(() => {
const params = new URLSearchParams();
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1)));
if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(","));
if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`;
}, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]);
return ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -266,47 +208,48 @@ export default function EntriesExplorer({
<ExplorerLayout <ExplorerLayout
title="Entries" title="Entries"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."} subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
chips={activeFilters}
onClearChips={resetFilters}
sidebar={( sidebar={(
<FilterSidebar> <FilterSidebar onReset={resetFilters} loading={loading}>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}> <Form onSubmit={onSubmit} className="d-flex flex-column gap-2">
<div> <InputGroup>
<label className="form-label small text-secondary">Search</label> <Form.Control
<input
type="text" type="text"
className="form-control"
placeholder="Search titles..." placeholder="Search titles..."
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
</div> <Button variant="primary" type="submit" disabled={loading}>
<div className="d-grid"> <Search size={14} />
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button> </Button>
</div> </InputGroup>
<div>
<label className="form-label small text-secondary">Genre</label> <FilterSection label="Genre" badge={genreId !== "" ? genres.find((g) => g.id === Number(genreId))?.name : undefined}>
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}> <Form.Select size="sm" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">All genres</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> </Form.Select>
</div> </FilterSection>
<div>
<label className="form-label small text-secondary">Language</label> <FilterSection label="Language" badge={languageId !== "" ? languages.find((l) => l.id === languageId)?.name : undefined}>
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}> <Form.Select size="sm" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</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> </Form.Select>
</div> </FilterSection>
<div>
<label className="form-label small text-secondary">Machine</label> <FilterSection
label="Machine"
badge={`${machinetypeIds.length} selected`}
defaultOpen={false}
>
<MultiSelectChips <MultiSelectChips
options={machineOptions} options={machineOptions}
selected={machinetypeIds} selected={machinetypeIds}
collapsible
onToggle={(id) => { onToggle={(id) => {
setMachinetypeIds((current) => { setMachinetypeIds((current) => {
const next = new Set(current); const next = new Set(current);
@@ -316,148 +259,158 @@ export default function EntriesExplorer({
next.add(id); next.add(id);
} }
const order = machineOptions.map((item) => item.id); const order = machineOptions.map((item) => item.id);
return order.filter((value) => next.has(value)); const filtered = order.filter((value) => next.has(value));
return filtered.length ? filtered : preferredMachineIds.slice();
}); });
setPage(1); setPage(1);
}} }}
/> />
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div> <div className="d-flex gap-2 mt-1">
</div> <Button
<div> variant="outline-secondary"
<label className="form-label small text-secondary">Sort</label> size="sm"
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}> onClick={() => { setMachinetypeIds(machineOptions.map((m) => m.id)); setPage(1); }}
<option value="title">Title (AZ)</option> disabled={machinetypeIds.length === machineOptions.length}
>
All
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => { setMachinetypeIds(preferredMachineIds.slice()); setPage(1); }}
disabled={machinetypeIds.join(",") === preferredMachineIds.join(",")}
>
Default
</Button>
</div>
</FilterSection>
<FilterSection label="Sort &amp; Scope">
<Form.Select size="sm" className="mb-2" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
<option value="title">Title (A-Z)</option>
<option value="id_desc">Newest</option> <option value="id_desc">Newest</option>
</select> </Form.Select>
</div> <Form.Select size="sm" value={scope} onChange={(e) => { setScope(e.target.value as EntrySearchScope); setPage(1); }}>
<div> <option value="title">Titles only</option>
<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">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option> <option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select> </Form.Select>
</div> </FilterSection>
{facets && (
<div> {facets && (facets.flags.hasAliases > 0 || facets.flags.hasOrigins > 0) && (
<div className="text-secondary small mb-1">Facets</div> <FilterSection label="Facets">
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-1">
<button <Button
type="button" size="sm"
className={`btn btn-sm ${scope === "title_aliases" ? "btn-primary" : "btn-outline-secondary"}`} variant={scope === "title_aliases" ? "primary" : "outline-secondary"}
onClick={() => { setScope("title_aliases"); setPage(1); }} onClick={() => { setScope("title_aliases"); setPage(1); }}
disabled={facets.flags.hasAliases === 0} disabled={facets.flags.hasAliases === 0}
title="Show results that match aliases"
> >
Has aliases ({facets.flags.hasAliases}) Aliases <Badge bg="light" text="dark" className="ms-1">{facets.flags.hasAliases}</Badge>
</button> </Button>
<button <Button
type="button" size="sm"
className={`btn btn-sm ${scope === "title_aliases_origins" ? "btn-primary" : "btn-outline-secondary"}`} variant={scope === "title_aliases_origins" ? "primary" : "outline-secondary"}
onClick={() => { setScope("title_aliases_origins"); setPage(1); }} onClick={() => { setScope("title_aliases_origins"); setPage(1); }}
disabled={facets.flags.hasOrigins === 0} disabled={facets.flags.hasOrigins === 0}
title="Show results that match origins"
> >
Has origins ({facets.flags.hasOrigins}) Origins <Badge bg="light" text="dark" className="ms-1">{facets.flags.hasOrigins}</Badge>
</button> </Button>
</div> </div>
</div> </FilterSection>
)} )}
{loading && <div className="text-secondary small">Loading...</div>}
</form> {error && <Alert variant="danger" className="py-1 px-2 small mb-0">{error}</Alert>}
</Form>
</FilterSidebar> </FilterSidebar>
)} )}
> >
{data && data.items.length === 0 && !loading && ( <div className={loading ? "opacity-50" : ""} style={{ transition: "opacity 0.15s" }}>
<div className="alert alert-warning">No results.</div> {data && data.items.length === 0 && !loading && (
)} <Alert variant="warning">
{data && data.items.length > 0 && ( No results found.
<div className="table-responsive"> {hasNonDefaultMachineFilter && (
<table className="table table-striped table-hover align-middle"> <span>
<thead> {" "}Filtering by{" "}
<tr> <strong>
<th style={{ width: 80 }}>ID</th> {machinetypeIds
<th>Title</th> .map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`)
<th style={{ width: 160 }}>Genre</th> .join(", ")}
<th style={{ width: 160 }}>Machine</th> </strong>
<th style={{ width: 120 }}>Language</th> {" "}&mdash;{" "}
</tr> <Alert.Link onClick={searchAllMachines}>
</thead> search all machines
<tbody> </Alert.Link>?
{data.items.map((it) => ( </span>
<tr key={it.id}> )}
<td><EntryLink id={it.id} /></td> </Alert>
<td><EntryLink id={it.id} title={it.title} /></td> )}
<td> {data && data.items.length > 0 && (
{it.genreId != null ? ( <div className="table-responsive">
it.genreName ? ( <Table striped hover className="align-middle">
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link> <thead>
) : ( <tr>
<span>{it.genreId}</span> <th style={{ width: 80 }}>ID</th>
) <th>Title</th>
) : ( <th style={{ width: 160 }}>Genre</th>
<span className="text-secondary">-</span> <th style={{ width: 160 }}>Machine</th>
)} <th style={{ width: 120 }}>Language</th>
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {data.items.map((it) => (
</div> <tr key={it.id}>
)} <td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.genreId != null ? (
it.genreName ? (
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link>
) : (
<span>{it.genreId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</Table>
</div>
)}
</div>
</ExplorerLayout> </ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? page}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link loading={loading}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} buildHref={buildHref}
aria-disabled={!data || data.page <= 1} onPageChange={setPage}
href={prevHref} />
onClick={(e) => {
if (!data || data.page <= 1) return;
e.preventDefault();
setPage((p) => Math.max(1, p - 1));
}}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={nextHref}
onClick={(e) => {
if (!data || data.page >= totalPages) return;
e.preventDefault();
setPage((p) => Math.min(totalPages, p + 1));
}}
>
Next
</Link>
</div>
</div>
<hr /> <hr />
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,16 +1,20 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import EntryDetailClient from "./EntryDetail"; import EntryDetailClient from "./EntryDetail";
import { getEntryById } from "@/server/repo/zxdb"; import { getEntryById } from "@/server/repo";
export const metadata = {
title: "ZXDB Entry",
};
export const revalidate = 3600; export const revalidate = 3600;
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const data = await getEntryById(Number(id));
if (!data) return { title: "Entry Not Found | ZXDB" };
return { title: `${data.title} | ZXDB Entry` };
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const numericId = Number(id); const data = await getEntryById(Number(id));
const data = await getEntryById(numericId); if (!data) notFound();
// For simplicity, let the client render a Not Found state if null
return <EntryDetailClient data={data} />; return <EntryDetailClient data={data} />;
} }

View File

@@ -1,5 +1,6 @@
import EntriesExplorer from "./EntriesExplorer"; import EntriesExplorer from "./EntriesExplorer";
import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb"; import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo";
import { parseIdList } from "@/utils/params";
export const metadata = { export const metadata = {
title: "ZXDB Entries", title: "ZXDB Entries",
@@ -7,16 +8,6 @@ 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);

View File

@@ -1,17 +1,20 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, 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 { Row, Col, Card, Form, Button, Alert, Table, Badge } from "react-bootstrap";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
import type { PagedResult } from "@/types/zxdb";
type Genre = { id: number; name: string }; type Genre = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Genre>; initialQ?: string }) { export default function GenresSearch({ initial, initialQ }: { initial?: PagedResult<Genre>; initialQ?: string }) {
const router = useRouter(); const router = useRouter();
const [q, setQ] = useState(initialQ ?? ""); const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Genre> | null>(initial ?? null); const [data, setData] = useState<PagedResult<Genre> | null>(initial ?? null);
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]);
useEffect(() => { useEffect(() => {
@@ -30,6 +33,13 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
router.push(`/zxdb/genres?${params.toString()}`); router.push(`/zxdb/genres?${params.toString()}`);
} }
const buildHref = useCallback((p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/genres?${params.toString()}`;
}, [q]);
return ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -46,69 +56,55 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
</div> </div>
</div> </div>
<div className="row g-3"> <Row className="g-3">
<div className="col-lg-3"> <Col lg={3}>
<div className="card shadow-sm"> <Card className="shadow-sm">
<div className="card-body"> <Card.Body>
<form className="d-flex flex-column gap-2" onSubmit={submit}> <Form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <Form.Group>
<label className="form-label small text-secondary">Search</label> <Form.Label className="small text-secondary">Search</Form.Label>
<input className="form-control" placeholder="Search genres" value={q} onChange={(e) => setQ(e.target.value)} /> <Form.Control placeholder="Search genres..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </Form.Group>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <Button variant="primary" type="submit">Search</Button>
</div> </div>
</form> </Form>
</div> </Card.Body>
</div> </Card>
</div> </Col>
<div className="col-lg-9"> <Col lg={9}>
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>} {data && data.items.length === 0 && <Alert variant="warning">No genres found.</Alert>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <Table striped hover className="align-middle">
<table className="table table-striped table-hover align-middle"> <caption className="visually-hidden">Genres search results</caption>
<thead> <thead>
<tr> <tr>
<th style={{ width: 120 }}>ID</th> <th style={{ width: 120 }}>ID</th>
<th>Name</th> <th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((g) => (
<tr key={g.id}>
<td><Badge bg="light" text="dark">#{g.id}</Badge></td>
<td>
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{data.items.map((g) => ( </Table>
<tr key={g.id}>
<td><span className="badge text-bg-light">#{g.id}</span></td>
<td>
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </Col>
</div> </Row>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -4,10 +4,11 @@ import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null }; import type { PagedResult } from "@/types/zxdb";
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) { type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: PagedResult<Item>; initialQ?: string }) {
const router = useRouter(); const router = useRouter();
const [q, setQ] = useState(initialQ ?? ""); const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
@@ -17,7 +18,7 @@ export default function GenreDetailClient({ id, initial, initialQ }: { id: numbe
<h1 className="mb-0">Genre <span className="badge text-bg-light">#{id}</span></h1> <h1 className="mb-0">Genre <span className="badge text-bg-light">#{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}> <form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4"> <div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this genre…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search within this genre…" aria-label="Search within this genre" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import GenreDetailClient from "./GenreDetail"; import GenreDetailClient from "./GenreDetail";
import { entriesByGenre } from "@/server/repo/zxdb"; import { entriesByGenre } from "@/server/repo";
export const metadata = { title: "ZXDB Genre" }; export const metadata = { title: "ZXDB Genre" };

View File

@@ -1,5 +1,5 @@
import GenresSearch from "./GenresSearch"; import GenresSearch from "./GenresSearch";
import { searchGenres } from "@/server/repo/zxdb"; import { searchGenres } from "@/server/repo";
export const metadata = { title: "ZXDB Genres" }; export const metadata = { title: "ZXDB Genres" };

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,6 +1,6 @@
import Link from "next/link"; 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";
import EntryLink from "@/app/zxdb/components/EntryLink"; import EntryLink from "@/app/zxdb/components/EntryLink";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
@@ -29,7 +29,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
/> />
<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}`}>&larr; 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>
{issue.linkMask && ( {issue.linkMask && (
<a className="btn btn-outline-secondary btn-sm" href={issue.linkMask} target="_blank" rel="noreferrer">Issue link</a> <a className="btn btn-outline-secondary btn-sm" href={issue.linkMask} target="_blank" rel="noreferrer">Issue link</a>
@@ -57,6 +57,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-sm align-middle"> <table className="table table-sm align-middle">
<caption className="visually-hidden">Issue references</caption>
<thead> <thead>
<tr> <tr>
<th style={{ width: 80 }}>Page</th> <th style={{ width: 80 }}>Page</th>
@@ -75,7 +76,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
) : r.labelId ? ( ) : r.labelId ? (
<Link href={`/zxdb/labels/${r.labelId}`}>{r.labelName ?? r.labelId}</Link> <Link href={`/zxdb/labels/${r.labelId}`}>{r.labelName ?? r.labelId}</Link>
) : ( ) : (
<span className="text-secondary"></span> <span className="text-secondary">&mdash;</span>
)} )}
</td> </td>
</tr> </tr>

View File

@@ -1,25 +1,26 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, 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 { Row, Col, Card, Form, Button, Alert, Table, Badge } from "react-bootstrap";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
import type { PagedResult } from "@/types/zxdb";
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 };
export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<Label>; initialQ?: string }) { export default function LabelsSearch({ initial, initialQ }: { initial?: PagedResult<Label>; initialQ?: string }) {
const router = useRouter(); const router = useRouter();
const [q, setQ] = useState(initialQ ?? ""); const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Label> | null>(initial ?? null); const [data, setData] = useState<PagedResult<Label> | null>(initial ?? null);
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]);
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
useEffect(() => { useEffect(() => {
if (initial) setData(initial); if (initial) setData(initial);
}, [initial]); }, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => { useEffect(() => {
setQ(initialQ ?? ""); setQ(initialQ ?? "");
}, [initialQ]); }, [initialQ]);
@@ -32,6 +33,13 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
router.push(`/zxdb/labels?${params.toString()}`); router.push(`/zxdb/labels?${params.toString()}`);
} }
const buildHref = useCallback((p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/labels?${params.toString()}`;
}, [q]);
return ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -48,73 +56,59 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
</div> </div>
</div> </div>
<div className="row g-3"> <Row className="g-3">
<div className="col-lg-3"> <Col lg={3}>
<div className="card shadow-sm"> <Card className="shadow-sm">
<div className="card-body"> <Card.Body>
<form className="d-flex flex-column gap-2" onSubmit={submit}> <Form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <Form.Group>
<label className="form-label small text-secondary">Search</label> <Form.Label className="small text-secondary">Search</Form.Label>
<input className="form-control" placeholder="Search labels" value={q} onChange={(e) => setQ(e.target.value)} /> <Form.Control placeholder="Search labels..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </Form.Group>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <Button variant="primary" type="submit">Search</Button>
</div> </div>
</form> </Form>
</div> </Card.Body>
</div> </Card>
</div> </Col>
<div className="col-lg-9"> <Col lg={9}>
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>} {data && data.items.length === 0 && <Alert variant="warning">No labels found.</Alert>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <Table striped hover className="align-middle">
<table className="table table-striped table-hover align-middle"> <caption className="visually-hidden">Labels search results</caption>
<thead> <thead>
<tr> <tr>
<th style={{ width: 100 }}>ID</th> <th style={{ width: 100 }}>ID</th>
<th>Name</th> <th>Name</th>
<th style={{ width: 120 }}>Type</th> <th style={{ width: 120 }}>Type</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td>#{l.id}</td>
<td>
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
</td>
<td>
<Badge bg="light" text="dark">{l.labeltypeId ?? "?"}</Badge>
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{data.items.map((l) => ( </Table>
<tr key={l.id}>
<td>#{l.id}</td>
<td>
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
</td>
<td>
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </Col>
</div> </Row>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -4,12 +4,19 @@ import Link from "next/link";
import EntryLink from "../../components/EntryLink"; 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";
import { Row, Col, Table, Nav, Form, Button, Alert, Badge } from "react-bootstrap";
type Label = { type Label = {
id: number; id: number;
name: string; name: string;
labeltypeId: string | null; labeltypeId: string | null;
labeltypeName: string | null; labeltypeName: string | null;
countryId: string | null;
countryName: string | null;
country2Id: string | null;
country2Name: string | null;
linkWikipedia: string | null;
linkSite: string | null;
permissions: { permissions: {
website: { id: number; name: string; link?: string | null }; website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null }; type: { id: string; name: string | null };
@@ -24,10 +31,11 @@ type Label = {
comments?: 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 }; import type { PagedResult } from "@/types/zxdb";
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> }; type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
type Payload = { label: Label | null; authored: PagedResult<Item>; published: PagedResult<Item> };
export default function LabelDetailClient({ id, initial, initialTab, initialQ }: { id: number; initial: Payload; initialTab?: "authored" | "published"; initialQ?: string }) { export default function LabelDetailClient({ id, initial, initialTab, initialQ }: { id: number; initial: Payload; initialTab?: "authored" | "published"; initialQ?: string }) {
// Keep only interactive UI state (tab). Data should come directly from SSR props so it updates on navigation. // Keep only interactive UI state (tab). Data should come directly from SSR props so it updates on navigation.
@@ -37,161 +45,169 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
// Names are now delivered by SSR payload to minimize pop-in. // Names are now delivered by SSR payload to minimize pop-in.
// Hooks must be called unconditionally // Hooks must be called unconditionally
const current = useMemo<Paged<Item> | null>( const current = useMemo<PagedResult<Item> | null>(
() => (tab === "authored" ? initial?.authored : initial?.published) ?? null, () => (tab === "authored" ? initial?.authored : initial?.published) ?? null,
[initial, tab] [initial, tab]
); );
const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]); const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]);
if (!initial || !initial.label) return <div className="alert alert-warning">Not found</div>; if (!initial || !initial.label) return <Alert variant="warning">Not found</Alert>;
return ( return (
<div> <div>
<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"> <Badge bg="light" text="dark">
{initial.label.labeltypeName {initial.label.labeltypeName
? `${initial.label.labeltypeName} (${initial.label.labeltypeId ?? "?"})` ? `${initial.label.labeltypeName} (${initial.label.labeltypeId ?? "?"})`
: (initial.label.labeltypeId ?? "?")} : (initial.label.labeltypeId ?? "?")}
</span> </Badge>
</div> </div>
</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"> <Row className="g-4 mt-1">
<div className="col-lg-6"> <Col lg={6}>
<h5>Permissions</h5> <h5>Permissions</h5>
{initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>} {initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>}
{initial.label.permissions.length > 0 && ( {initial.label.permissions.length > 0 && (
<div className="table-responsive"> <Table size="sm" striped className="align-middle">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 140 }}>Type</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{initial.label.permissions.map((p, idx) => (
<tr key={`${p.website.id}-${p.type.id}-${idx}`}>
<td>
{p.website.link ? (
<a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
) : (
<span>{p.website.name}</span>
)}
</td>
<td>{p.type.name ?? p.type.id}</td>
<td>{p.text ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="col-lg-6">
<h5>Licenses</h5>
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
{initial.label.licenses.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th style={{ width: 140 }}>Type</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{initial.label.licenses.map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td>{l.type.name ?? l.type.id}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{l.linkWikipedia && (
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
)}
{l.linkSite && (
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
)}
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<ul className="nav nav-tabs mt-3">
<li className="nav-item">
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
</li>
<li className="nav-item">
<button className={`nav-link ${tab === "published" ? "active" : ""}`} onClick={() => setTab("published")}>Published</button>
</li>
</ul>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder={`Search within ${tab}`} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style={{ width: 80 }}>ID</th> <th>Website</th>
<th>Title</th> <th style={{ width: 140 }}>Type</th>
<th style={{ width: 160 }}>Machine</th> <th>Notes</th>
<th style={{ width: 120 }}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{current.items.map((it) => ( {initial.label.permissions.map((p, idx) => (
<tr key={it.id}> <tr key={`${p.website.id}-${p.type.id}-${idx}`}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td> <td>
{it.machinetypeId != null ? ( {p.website.link ? (
it.machinetypeName ? ( <a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : ( ) : (
<span className="text-secondary">-</span> <span>{p.website.name}</span>
)} )}
</td> </td>
<td>{p.type.name ?? p.type.id}</td>
<td>{p.text ?? ""}</td>
</tr>
))}
</tbody>
</Table>
)}
</Col>
<Col lg={6}>
<h5>Licenses</h5>
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
{initial.label.licenses.length > 0 && (
<Table size="sm" striped className="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> <td>
{it.languageId ? ( <div className="d-flex gap-2 flex-wrap">
it.languageName ? ( {l.linkWikipedia && (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link> <a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
) : ( )}
<span>{it.languageId}</span> {l.linkSite && (
) <a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
) : ( )}
<span className="text-secondary">-</span> {!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
)} </div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </Table>
</div> )}
</Col>
</Row>
<Nav variant="tabs" className="mt-3" activeKey={tab} onSelect={(k) => setTab(k as "authored" | "published")}>
<Nav.Item>
<Nav.Link eventKey="authored">Authored</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="published">Published</Nav.Link>
</Nav.Item>
</Nav>
<Form className="d-flex align-items-center gap-2 mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}>
<Form.Control className="w-auto" style={{ minWidth: 200 }} placeholder={`Search within ${tab}`} aria-label={`Search within ${tab}`} value={q} onChange={(e) => setQ(e.target.value)} />
<Button variant="primary" type="submit">Search</Button>
</Form>
<div className="mt-3">
{current && current.items.length === 0 && <Alert variant="warning">No entries.</Alert>}
{current && current.items.length > 0 && (
<Table striped hover className="align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
{current.items.map((it) => (
<tr key={it.id}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</Table>
)} )}
</div> </div>

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,7 +1,13 @@
import type { Metadata } from "next";
import LabelDetailClient from "./LabelDetail"; import LabelDetailClient from "./LabelDetail";
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb"; import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo";
export const metadata = { title: "ZXDB Label" }; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const label = await getLabelById(Number(id));
if (!label) return { title: "Label Not Found | ZXDB" };
return { title: `${label.name} | ZXDB Label` };
}
// Depends on searchParams (?page=, ?tab=). Force dynamic so each request renders correctly. // Depends on searchParams (?page=, ?tab=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,5 +1,5 @@
import LabelsSearch from "./LabelsSearch"; import LabelsSearch from "./LabelsSearch";
import { searchLabels } from "@/server/repo/zxdb"; import { searchLabels } from "@/server/repo";
export const metadata = { title: "ZXDB Labels" }; export const metadata = { title: "ZXDB Labels" };

View File

@@ -1,17 +1,20 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, 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 { Row, Col, Card, Form, Button, Alert, Table, Badge } from "react-bootstrap";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
import type { PagedResult } from "@/types/zxdb";
type Language = { id: string; name: string }; type Language = { id: string; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged<Language>; initialQ?: string }) { export default function LanguagesSearch({ initial, initialQ }: { initial?: PagedResult<Language>; initialQ?: string }) {
const router = useRouter(); const router = useRouter();
const [q, setQ] = useState(initialQ ?? ""); const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Language> | null>(initial ?? null); const [data, setData] = useState<PagedResult<Language> | null>(initial ?? null);
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]);
useEffect(() => { useEffect(() => {
@@ -30,6 +33,13 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
router.push(`/zxdb/languages?${params.toString()}`); router.push(`/zxdb/languages?${params.toString()}`);
} }
const buildHref = useCallback((p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/languages?${params.toString()}`;
}, [q]);
return ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -46,69 +56,55 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
</div> </div>
</div> </div>
<div className="row g-3"> <Row className="g-3">
<div className="col-lg-3"> <Col lg={3}>
<div className="card shadow-sm"> <Card className="shadow-sm">
<div className="card-body"> <Card.Body>
<form className="d-flex flex-column gap-2" onSubmit={submit}> <Form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <Form.Group>
<label className="form-label small text-secondary">Search</label> <Form.Label className="small text-secondary">Search</Form.Label>
<input className="form-control" placeholder="Search languages" value={q} onChange={(e) => setQ(e.target.value)} /> <Form.Control placeholder="Search languages..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </Form.Group>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <Button variant="primary" type="submit">Search</Button>
</div> </div>
</form> </Form>
</div> </Card.Body>
</div> </Card>
</div> </Col>
<div className="col-lg-9"> <Col lg={9}>
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>} {data && data.items.length === 0 && <Alert variant="warning">No languages found.</Alert>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <Table striped hover className="align-middle">
<table className="table table-striped table-hover align-middle"> <caption className="visually-hidden">Languages search results</caption>
<thead> <thead>
<tr> <tr>
<th style={{ width: 120 }}>Code</th> <th style={{ width: 120 }}>Code</th>
<th>Name</th> <th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td><Badge bg="light" text="dark">{l.id}</Badge></td>
<td>
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{data.items.map((l) => ( </Table>
<tr key={l.id}>
<td><span className="badge text-bg-light">{l.id}</span></td>
<td>
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </Col>
</div> </Row>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -4,10 +4,11 @@ import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null }; import type { PagedResult } from "@/types/zxdb";
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: Paged<Item>; initialQ?: string }) { type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: PagedResult<Item>; initialQ?: string }) {
const router = useRouter(); const router = useRouter();
const [q, setQ] = useState(initialQ ?? ""); const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
@@ -17,7 +18,7 @@ export default function LanguageDetailClient({ id, initial, initialQ }: { id: st
<h1 className="mb-0">Language <span className="badge text-bg-light">{id}</span></h1> <h1 className="mb-0">Language <span className="badge text-bg-light">{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}> <form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4"> <div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this language…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search within this language…" aria-label="Search within this language" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import LanguageDetailClient from "./LanguageDetail"; import LanguageDetailClient from "./LanguageDetail";
import { entriesByLanguage } from "@/server/repo/zxdb"; import { entriesByLanguage } from "@/server/repo";
export const metadata = { title: "ZXDB Language" }; export const metadata = { title: "ZXDB Language" };

View File

@@ -1,5 +1,5 @@
import LanguagesSearch from "./LanguagesSearch"; import LanguagesSearch from "./LanguagesSearch";
import { searchLanguages } from "@/server/repo/zxdb"; import { searchLanguages } from "@/server/repo";
export const metadata = { title: "ZXDB Languages" }; export const metadata = { title: "ZXDB Languages" };

View File

@@ -1,25 +1,26 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, 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 { Row, Col, Card, Form, Button, Alert, Table, Badge } from "react-bootstrap";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
import type { PagedResult } from "@/types/zxdb";
type MT = { id: number; name: string }; type MT = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged<MT>; initialQ?: string }) { export default function MachineTypesSearch({ initial, initialQ }: { initial?: PagedResult<MT>; initialQ?: string }) {
const router = useRouter(); const router = useRouter();
const [q, setQ] = useState(initialQ ?? ""); const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<MT> | null>(initial ?? null); const [data, setData] = useState<PagedResult<MT> | null>(initial ?? null);
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]);
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
useEffect(() => { useEffect(() => {
if (initial) setData(initial); if (initial) setData(initial);
}, [initial]); }, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => { useEffect(() => {
setQ(initialQ ?? ""); setQ(initialQ ?? "");
}, [initialQ]); }, [initialQ]);
@@ -32,6 +33,13 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
router.push(`/zxdb/machinetypes?${params.toString()}`); router.push(`/zxdb/machinetypes?${params.toString()}`);
} }
const buildHref = useCallback((p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/machinetypes?${params.toString()}`;
}, [q]);
return ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -48,69 +56,55 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
</div> </div>
</div> </div>
<div className="row g-3"> <Row className="g-3">
<div className="col-lg-3"> <Col lg={3}>
<div className="card shadow-sm"> <Card className="shadow-sm">
<div className="card-body"> <Card.Body>
<form className="d-flex flex-column gap-2" onSubmit={submit}> <Form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <Form.Group>
<label className="form-label small text-secondary">Search</label> <Form.Label className="small text-secondary">Search</Form.Label>
<input className="form-control" placeholder="Search machine types" value={q} onChange={(e) => setQ(e.target.value)} /> <Form.Control placeholder="Search machine types..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </Form.Group>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <Button variant="primary" type="submit">Search</Button>
</div> </div>
</form> </Form>
</div> </Card.Body>
</div> </Card>
</div> </Col>
<div className="col-lg-9"> <Col lg={9}>
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>} {data && data.items.length === 0 && <Alert variant="warning">No machine types found.</Alert>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <Table striped hover className="align-middle">
<table className="table table-striped table-hover align-middle"> <caption className="visually-hidden">Machine types search results</caption>
<thead> <thead>
<tr> <tr>
<th style={{ width: 120 }}>ID</th> <th style={{ width: 120 }}>ID</th>
<th>Name</th> <th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id}>
<td><Badge bg="light" text="dark">#{m.id}</Badge></td>
<td>
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{data.items.map((m) => ( </Table>
<tr key={m.id}>
<td><span className="badge text-bg-light">#{m.id}</span></td>
<td>
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </Col>
</div> </Row>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
>
Next
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -13,9 +13,9 @@ type Item = {
languageId: string | null; languageId: string | null;
languageName?: string | null; languageName?: string | null;
}; };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; import type { PagedResult } from "@/types/zxdb";
export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) { export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: PagedResult<Item>; initialQ?: string }) {
const router = useRouter(); const router = useRouter();
const [q, setQ] = useState(initialQ ?? ""); const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
@@ -29,7 +29,7 @@ export default function MachineTypeDetailClient({ id, initial, initialQ }: { id:
<h1 className="mb-0">{machineName ?? "Machine Type"} <span className="badge text-bg-light">#{id}</span></h1> <h1 className="mb-0">{machineName ?? "Machine Type"} <span className="badge text-bg-light">#{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}> <form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4"> <div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this machine type…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search within this machine type…" aria-label="Search within this machine type" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import MachineTypeDetailClient from "./MachineTypeDetail"; import MachineTypeDetailClient from "./MachineTypeDetail";
import { entriesByMachinetype } from "@/server/repo/zxdb"; import { entriesByMachinetype } from "@/server/repo";
export const metadata = { title: "ZXDB Machine Type" }; export const metadata = { title: "ZXDB Machine Type" };
// Depends on searchParams (?page=). Force dynamic so each page renders correctly. // Depends on searchParams (?page=). Force dynamic so each page renders correctly.

View File

@@ -1,5 +1,5 @@
import MachineTypesSearch from "./MachineTypesSearch"; import MachineTypesSearch from "./MachineTypesSearch";
import { searchMachinetypes } from "@/server/repo/zxdb"; import { searchMachinetypes } from "@/server/repo";
export const metadata = { title: "ZXDB Machine Types" }; export const metadata = { title: "ZXDB Machine Types" };

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getMagazine } from "@/server/repo/zxdb"; import { getMagazine } from "@/server/repo";
import { Link45deg, Archive } from "react-bootstrap-icons";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazine" }; export const metadata = { title: "ZXDB Magazine" };
@@ -28,7 +29,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
<div className="text-secondary mb-3">Language: {mag.languageId}</div> <div className="text-secondary mb-3">Language: {mag.languageId}</div>
<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"> Back to list</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">&larr; Back to list</Link>
{mag.linkSite && ( {mag.linkSite && (
<a className="btn btn-outline-secondary btn-sm" href={mag.linkSite} target="_blank" rel="noreferrer"> <a className="btn btn-outline-secondary btn-sm" href={mag.linkSite} target="_blank" rel="noreferrer">
Official site Official site
@@ -42,6 +43,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-sm align-middle"> <table className="table table-sm align-middle">
<caption className="visually-hidden">Magazine issues</caption>
<thead> <thead>
<tr> <tr>
<th style={{ width: 200 }}>Issue</th> <th style={{ width: 200 }}>Issue</th>
@@ -71,13 +73,13 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
{i.linkMask && ( {i.linkMask && (
<a className="btn btn-outline-secondary btn-sm" href={i.linkMask} target="_blank" rel="noreferrer" title="Link"> <a className="btn btn-outline-secondary btn-sm" href={i.linkMask} target="_blank" rel="noreferrer" title="Link">
<span className="bi bi-link-45deg" aria-hidden /> <Link45deg aria-hidden />
<span className="visually-hidden">Link</span> <span className="visually-hidden">Link</span>
</a> </a>
)} )}
{i.archiveMask && ( {i.archiveMask && (
<a className="btn btn-outline-secondary btn-sm" href={i.archiveMask} target="_blank" rel="noreferrer" title="Archive"> <a className="btn btn-outline-secondary btn-sm" href={i.archiveMask} target="_blank" rel="noreferrer" title="Archive">
<span className="bi bi-archive" aria-hidden /> <Archive aria-hidden />
<span className="visually-hidden">Archive</span> <span className="visually-hidden">Archive</span>
</a> </a>
)} )}

View File

@@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { listMagazines } from "@/server/repo/zxdb"; import { listMagazines } from "@/server/repo";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazines" }; export const metadata = { title: "ZXDB Magazines" };
@@ -17,6 +17,14 @@ export default async function Page({
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 data = await listMagazines({ q, page, pageSize: 20 }); const data = await listMagazines({ q, page, pageSize: 20 });
const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
function makeHref(p: number) {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/magazines?${params.toString()}`;
}
return ( return (
<div> <div>
@@ -54,6 +62,7 @@ export default async function Page({
<div className="col-lg-9"> <div className="col-lg-9">
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
<caption className="visually-hidden">Magazines search results</caption>
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Title</th>
@@ -81,37 +90,21 @@ export default async function Page({
</div> </div>
</div> </div>
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} /> {totalPages > 1 && (
<nav className="mt-3" aria-label="Pagination">
<ul className="pagination">
<li className={`page-item ${page <= 1 ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.max(1, page - 1))}>Previous</Link>
</li>
<li className="page-item disabled">
<span className="page-link">Page {page} of {totalPages}</span>
</li>
<li className={`page-item ${page >= totalPages ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.min(totalPages, page + 1))}>Next</Link>
</li>
</ul>
</nav>
)}
</div> </div>
); );
} }
function Pagination({ page, pageSize, total, q }: { page: number; pageSize: number; total: number; q: string }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const makeHref = (p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/magazines?${params.toString()}`;
};
return (
<nav className="mt-3" aria-label="Pagination">
<ul className="pagination">
<li className={`page-item ${page <= 1 ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.max(1, page - 1))}>
Previous
</Link>
</li>
<li className="page-item disabled">
<span className="page-link">Page {page} of {totalPages}</span>
</li>
<li className={`page-item ${page >= totalPages ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.min(totalPages, page + 1))}>
Next
</Link>
</li>
</ul>
</nav>
);
}

View File

@@ -1,4 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { Collection, BoxArrowDown, JournalText, People } from "react-bootstrap-icons";
import TapeIdentifier from "./TapeIdentifier";
export const metadata = { export const metadata = {
title: "ZXDB Explorer", title: "ZXDB Explorer",
@@ -18,8 +20,8 @@ export default async function Page() {
<div className="row align-items-center g-4"> <div className="row align-items-center g-4">
<div className="col-lg-7"> <div className="col-lg-7">
<div className="d-flex align-items-center gap-2 mb-3"> <div className="d-flex align-items-center gap-2 mb-3">
<span className="badge text-bg-dark">ZXDB</span> <span className="badge bg-dark">ZXDB</span>
<span className="badge text-bg-secondary">Explorer</span> <span className="badge bg-secondary">Explorer</span>
</div> </div>
<h1 className="display-6 mb-3">ZXDB Explorer</h1> <h1 className="display-6 mb-3">ZXDB Explorer</h1>
<p className="lead text-secondary mb-4"> <p className="lead text-secondary mb-4">
@@ -29,14 +31,14 @@ export default async function Page() {
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/entries">Browse entries</Link> <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/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/magazines">Magazine issues</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">People & labels</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">People &amp; labels</Link>
</div> </div>
</div> </div>
<div className="col-lg-5"> <div className="col-lg-5">
<div className="card border-0 shadow-sm"> <div className="card border-0 shadow-sm">
<div className="card-body"> <div className="card-body">
<h5 className="card-title mb-3">Jump straight in</h5> <h5 className="card-title mb-3">Jump straight in</h5>
<form className="d-flex flex-column gap-2" method="get" action="/zxdb/entries"> <form method="get" action="/zxdb/entries" className="d-flex flex-column gap-2">
<div> <div>
<label className="form-label small text-secondary">Search entries</label> <label className="form-label small text-secondary">Search entries</label>
<input className="form-control" name="q" placeholder="Try: manic, doom, renegade..." /> <input className="form-control" name="q" placeholder="Try: manic, doom, renegade..." />
@@ -49,7 +51,7 @@ export default async function Page() {
<option value="title_aliases_origins">Titles + Aliases + Origins</option> <option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select> </select>
</div> </div>
<button className="btn btn-primary">Search</button> <button className="btn btn-primary" type="submit">Search</button>
</form> </form>
</div> </div>
</div> </div>
@@ -57,6 +59,20 @@ export default async function Page() {
</div> </div>
</section> </section>
<section>
<div className="row g-3">
<div className="col-lg-8">
<TapeIdentifier />
</div>
<div className="col-lg-4 d-flex align-items-center">
<p className="text-secondary small mb-0">
Drop a <code>.tap</code>, <code>.tzx</code>, or other tape file to identify it against 32,000+ ZXDB entries.
The file stays in your browser &mdash; only its hash is sent.
</p>
</div>
</div>
</section>
<section> <section>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h2 className="h4 mb-0">Start exploring</h2> <h2 className="h4 mb-0">Start exploring</h2>
@@ -68,10 +84,10 @@ export default async function Page() {
<div className="card h-100 shadow-sm"> <div className="card h-100 shadow-sm">
<div className="card-body"> <div className="card-body">
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">
<span className="bi bi-collection" style={{ fontSize: 28 }} aria-hidden /> <Collection size={28} aria-hidden />
<div> <div>
<h5 className="card-title mb-1">Entries</h5> <h5 className="card-title mb-1">Entries</h5>
<div className="card-text text-secondary">Search + filter titles</div> <p className="card-text text-secondary">Search + filter titles</p>
</div> </div>
</div> </div>
</div> </div>
@@ -83,10 +99,10 @@ export default async function Page() {
<div className="card h-100 shadow-sm"> <div className="card h-100 shadow-sm">
<div className="card-body"> <div className="card-body">
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} aria-hidden /> <BoxArrowDown size={28} aria-hidden />
<div> <div>
<h5 className="card-title mb-1">Releases</h5> <h5 className="card-title mb-1">Releases</h5>
<div className="card-text text-secondary">Downloads + media</div> <p className="card-text text-secondary">Downloads + media</p>
</div> </div>
</div> </div>
</div> </div>
@@ -98,10 +114,10 @@ export default async function Page() {
<div className="card h-100 shadow-sm"> <div className="card h-100 shadow-sm">
<div className="card-body"> <div className="card-body">
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">
<span className="bi bi-journal-text" style={{ fontSize: 28 }} aria-hidden /> <JournalText size={28} aria-hidden />
<div> <div>
<h5 className="card-title mb-1">Magazines</h5> <h5 className="card-title mb-1">Magazines</h5>
<div className="card-text text-secondary">Issues + references</div> <p className="card-text text-secondary">Issues + references</p>
</div> </div>
</div> </div>
</div> </div>
@@ -113,10 +129,10 @@ export default async function Page() {
<div className="card h-100 shadow-sm"> <div className="card h-100 shadow-sm">
<div className="card-body"> <div className="card-body">
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">
<span className="bi bi-people" style={{ fontSize: 28 }} aria-hidden /> <People size={28} aria-hidden />
<div> <div>
<h5 className="card-title mb-1">Labels</h5> <h5 className="card-title mb-1">Labels</h5>
<div className="card-text text-secondary">People + publishers</div> <p className="card-text text-secondary">People + publishers</p>
</div> </div>
</div> </div>
</div> </div>
@@ -126,31 +142,33 @@ export default async function Page() {
</div> </div>
</section> </section>
<section className="row g-3"> <section>
<div className="col-lg-7"> <div className="row g-3">
<div className="card h-100 shadow-sm"> <div className="col-lg-7">
<div className="card-body"> <div className="card h-100 shadow-sm">
<h3 className="h5">Explore by category</h3> <div className="card-body">
<p className="text-secondary mb-3">Jump to curated lists and filter results from there.</p> <h3 className="h5">Explore by category</h3>
<div className="d-flex flex-wrap gap-2"> <p className="text-secondary mb-3">Jump to curated lists and filter results from there.</p>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link> <div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div className="col-lg-5">
<div className="col-lg-5"> <div className="card h-100 shadow-sm">
<div className="card h-100 shadow-sm"> <div className="card-body">
<div className="card-body"> <h3 className="h5">How to use this</h3>
<h3 className="h5">How to use this</h3> <ol className="mb-0 text-secondary small">
<ol className="mb-0 text-secondary small"> <li>Search by title or aliases in Entries.</li>
<li>Search by title or aliases in Entries.</li> <li>Open a release to see downloads, scraps, and places.</li>
<li>Open a release to see downloads, scraps, and places.</li> <li>Use magazines to find original reviews and references.</li>
<li>Use magazines to find original reviews and references.</li> <li>Follow labels to discover related work.</li>
<li>Follow labels to discover related work.</li> </ol>
</ol> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,23 +2,21 @@
import { useCallback, 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 { Form, InputGroup, Button, Table, Alert } from "react-bootstrap";
import { Search } from "react-bootstrap-icons";
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 ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout"; import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar"; import FilterSidebar from "@/components/explorer/FilterSidebar";
import FilterSection from "@/components/explorer/FilterSection";
import MultiSelectChips from "@/components/explorer/MultiSelectChips"; import MultiSelectChips from "@/components/explorer/MultiSelectChips";
import Pagination from "@/components/explorer/Pagination";
import useSearchFetch from "@/hooks/useSearchFetch";
import type { PagedResult } from "@/types/zxdb";
import { preferredMachineIds, parseMachineIds } from "@/utils/params";
const preferredMachineIds = [27, 26, 8, 9]; const PAGE_SIZE = 20;
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;
@@ -28,32 +26,24 @@ type Item = {
magrefCount: number; magrefCount: number;
}; };
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
export default function ReleasesExplorer({ export default function ReleasesExplorer({
initial, initial,
initialUrlState, initialUrlState,
initialUrlHasParams,
initialLists, initialLists,
}: { }: {
initial?: Paged<Item>; initial?: PagedResult<Item>;
initialUrlState?: { initialUrlState?: {
q: string; q: string;
page: number; page: number;
year: string; year: string;
sort: "year_desc" | "year_asc" | "title" | "entry_id_desc"; sort: "year_desc" | "year_asc" | "title" | "entry_id_desc";
dLanguageId?: string; dLanguageId?: string;
dMachinetypeId?: string; // keep as string for URL/state consistency dMachinetypeId?: string;
filetypeId?: string; filetypeId?: string;
schemetypeId?: string; schemetypeId?: string;
sourcetypeId?: string; sourcetypeId?: string;
casetypeId?: string; casetypeId?: string;
isDemo?: string; // "1" or "true" isDemo?: string;
}; };
initialUrlHasParams?: boolean; initialUrlHasParams?: boolean;
initialLists?: { initialLists?: {
@@ -68,15 +58,12 @@ export default function ReleasesExplorer({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
// -- Search state --
const [q, setQ] = useState(initialUrlState?.q ?? ""); const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = 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 [data, setData] = useState<Paged<Item> | null>(initial ?? null);
const [year, setYear] = useState<string>(initialUrlState?.year ?? ""); const [year, setYear] = useState<string>(initialUrlState?.year ?? "");
const [sort, setSort] = useState<"year_desc" | "year_asc" | "title" | "entry_id_desc">(initialUrlState?.sort ?? "year_desc"); const [sort, setSort] = useState<"year_desc" | "year_asc" | "title" | "entry_id_desc">(initialUrlState?.sort ?? "year_desc");
// Download-based filters and their option lists
const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? ""); const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? "");
const [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.dMachinetypeId)); const [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.dMachinetypeId));
const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? ""); const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? "");
@@ -85,53 +72,52 @@ export default function ReleasesExplorer({
const [casetypeId, setCasetypeId] = useState<string>(initialUrlState?.casetypeId ?? ""); const [casetypeId, setCasetypeId] = useState<string>(initialUrlState?.casetypeId ?? "");
const [isDemo, setIsDemo] = useState<boolean>(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true"))); const [isDemo, setIsDemo] = useState<boolean>(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true")));
// -- Filter lists --
const [langs, setLangs] = useState<{ id: string; name: string }[]>(initialLists?.languages ?? []); const [langs, setLangs] = useState<{ id: string; name: string }[]>(initialLists?.languages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []); const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []);
const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []); const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []);
const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []); const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []);
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 preferredMachineNames = useMemo(() => { // -- Fetch with abort control --
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`); const { data, loading, error, fetch: doFetch, syncData } = useSearchFetch<Item>(
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); "/api/zxdb/releases/search",
}, [machines]); initial ?? null,
);
const isFirstRender = useRef(true);
// Debounce timer for year input changes
const yearDebounce = useRef<ReturnType<typeof setTimeout>>(undefined);
const totalPages = useMemo(
() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1),
[data],
);
const orderedMachines = useMemo(() => { const orderedMachines = useMemo(() => {
const seen = new Set(preferredMachineIds); const seen = new Set(preferredMachineIds);
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[]; 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)); const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest]; return [...preferred, ...rest];
}, [machines]); }, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20; const machineOptions = useMemo(
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); () => orderedMachines.map((m) => ({ id: m.id, label: m.name })),
[orderedMachines],
);
const updateUrl = useCallback((nextPage = page) => { const hasNonDefaultMachineFilter = dMachinetypeIds.join(",") !== preferredMachineIds.join(",") ||
const params = new URLSearchParams(); dMachinetypeIds.length !== machines.length;
if (appliedQ) params.set("q", appliedQ);
params.set("page", String(nextPage));
if (year) params.set("year", year);
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname);
}, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, page, pathname, router, schemetypeId, sort, sourcetypeId, year]);
const fetchData = useCallback(async (query: string, p: number) => { // -- URL helpers --
setLoading(true); const buildParams = useCallback(
try { (p: number) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query) params.set("q", query); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(p)); params.set("page", String(p));
params.set("pageSize", String(pageSize)); if (year) params.set("year", 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 (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
@@ -140,79 +126,44 @@ export default function ReleasesExplorer({
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");
const res = await fetch(`/api/zxdb/releases/search?${params.toString()}`); return params;
if (!res.ok) throw new Error(`Failed: ${res.status}`); },
const json: Paged<Item> = await res.json(); [appliedQ, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo],
setData(json); );
} catch (e) {
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}, [casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, pageSize, schemetypeId, sort, sourcetypeId, year]);
const buildHref = useCallback(
(p: number) => {
const qs = buildParams(p).toString();
return qs ? `${pathname}?${qs}` : pathname;
},
[buildParams, pathname],
);
// -- Fetch + URL sync on filter/page changes --
useEffect(() => { useEffect(() => {
if (initial) { if (isFirstRender.current) {
setData(initial); isFirstRender.current = false;
setPage(initial.page); router.replace(buildHref(page), { scroll: false });
}
}, [initial]);
const initialState = useMemo(() => ({
q: initialUrlState?.q ?? "",
year: initialUrlState?.year ?? "",
sort: initialUrlState?.sort ?? "year_desc",
dLanguageId: initialUrlState?.dLanguageId ?? "",
dMachinetypeId: initialUrlState?.dMachinetypeId ?? "",
filetypeId: initialUrlState?.filetypeId ?? "",
schemetypeId: initialUrlState?.schemetypeId ?? "",
sourcetypeId: initialUrlState?.sourcetypeId ?? "",
casetypeId: initialUrlState?.casetypeId ?? "",
isDemo: initialUrlState?.isDemo,
}), [initialUrlState]);
useEffect(() => {
const initialPage = initial?.page ?? 1;
if (
initial &&
page === initialPage &&
initialState.q === appliedQ &&
initialState.year === (year ?? "") &&
sort === initialState.sort &&
initialState.dLanguageId === dLanguageId &&
parseMachineIds(initialState.dMachinetypeId).join(",") === dMachinetypeIds.join(",") &&
initialState.filetypeId === filetypeId &&
initialState.schemetypeId === schemetypeId &&
initialState.sourcetypeId === sourcetypeId &&
initialState.casetypeId === casetypeId &&
(!!initialState.isDemo === isDemo)
) {
if (initialLoad.current) {
initialLoad.current = false;
return;
}
updateUrl(page);
return; return;
} }
if (initialLoad.current) {
initialLoad.current = false;
if (initial && !initialUrlHasParams) return;
}
updateUrl(page);
fetchData(appliedQ, page);
}, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, fetchData, filetypeId, initial, initialState, initialUrlHasParams, isDemo, page, schemetypeId, sort, sourcetypeId, updateUrl, year]);
function onSubmit(e: React.FormEvent) { router.replace(buildHref(page), { scroll: false });
e.preventDefault();
setAppliedQ(q);
setPage(1);
}
// Load filter option lists on mount const params = buildParams(page);
params.set("pageSize", String(PAGE_SIZE));
doFetch(params);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, appliedQ, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
// Sync SSR data when navigating (browser back/forward)
useEffect(() => { useEffect(() => {
if (initial) syncData(initial);
}, [initial, syncData]);
// Load filter lists on mount if not provided by server
useEffect(() => {
if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return;
async function loadLists() { async function loadLists() {
if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return;
try { try {
const [l, m, ft, sc, so, ca] = await Promise.all([ const [l, m, ft, sc, so, ca] = await Promise.all([
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()), fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
@@ -228,44 +179,42 @@ export default function ReleasesExplorer({
setSchemes(sc.items ?? []); setSchemes(sc.items ?? []);
setSources(so.items ?? []); setSources(so.items ?? []);
setCases(ca.items ?? []); setCases(ca.items ?? []);
} catch { } catch { /* filter lists are non-critical */ }
// ignore
}
} }
loadLists(); loadLists();
}, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]); }, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]);
const prevHref = useMemo(() => { function onSubmit(e: React.FormEvent) {
const params = new URLSearchParams(); e.preventDefault();
if (appliedQ) params.set("q", appliedQ); setAppliedQ(q);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); setPage(1);
if (year) params.set("year", year); }
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
if (filetypeId) params.set("filetypeId", filetypeId);
if (schemetypeId) params.set("schemetypeId", schemetypeId);
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1");
return `/zxdb/releases?${params.toString()}`;
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
const nextHref = useMemo(() => { function onYearChange(value: string) {
const params = new URLSearchParams(); setYear(value);
if (appliedQ) params.set("q", appliedQ); clearTimeout(yearDebounce.current);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1))); yearDebounce.current = setTimeout(() => setPage(1), 400);
if (year) params.set("year", year); }
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); function searchAllMachines() {
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); setDMachinetypeIds(machineOptions.map((m) => m.id));
if (filetypeId) params.set("filetypeId", filetypeId); setPage(1);
if (schemetypeId) params.set("schemetypeId", schemetypeId); }
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId); function resetFilters() {
if (isDemo) params.set("isDemo", "1"); setQ("");
return `/zxdb/releases?${params.toString()}`; setAppliedQ("");
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); setYear("");
setSort("year_desc");
setDLanguageId("");
setDMachinetypeIds(preferredMachineIds.slice());
setFiletypeId("");
setSchemetypeId("");
setSourcetypeId("");
setCasetypeId("");
setIsDemo(false);
setPage(1);
}
return ( return (
<div> <div>
@@ -280,121 +229,159 @@ export default function ReleasesExplorer({
title="Releases" title="Releases"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."} subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
sidebar={( sidebar={(
<FilterSidebar> <FilterSidebar onReset={resetFilters} loading={loading}>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}> <Form onSubmit={onSubmit} className="d-flex flex-column gap-2">
<div> <InputGroup>
<label className="form-label small text-secondary">Search title</label> <Form.Control
<input type="text"
type="text" placeholder="Search titles..."
className="form-control" value={q}
placeholder="Filter by entry title..." onChange={(e) => setQ(e.target.value)}
value={q} />
onChange={(e) => setQ(e.target.value)} <Button variant="primary" type="submit" disabled={loading}>
/> <Search size={14} />
</Button>
</InputGroup>
<FilterSection label="Year" badge={year || undefined}>
<Form.Control
type="number"
size="sm"
placeholder="Any"
value={year}
onChange={(e) => onYearChange(e.target.value)}
/>
</FilterSection>
<FilterSection label="Language" badge={dLanguageId ? langs.find((l) => l.id === dLanguageId)?.name : undefined}>
<Form.Select size="sm" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{langs.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</Form.Select>
</FilterSection>
<FilterSection
label="Machine"
badge={`${dMachinetypeIds.length} selected`}
defaultOpen={false}
>
<MultiSelectChips
options={machineOptions}
selected={dMachinetypeIds}
collapsible
onToggle={(id) => {
setDMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
const order = machineOptions.map((item) => item.id);
const filtered = order.filter((value) => next.has(value));
return filtered.length ? filtered : preferredMachineIds.slice();
});
setPage(1);
}}
/>
<div className="d-flex gap-2 mt-1">
<Button
variant="outline-secondary"
size="sm"
onClick={() => { setDMachinetypeIds(machineOptions.map((m) => m.id)); setPage(1); }}
disabled={dMachinetypeIds.length === machineOptions.length}
>
All
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => { setDMachinetypeIds(preferredMachineIds.slice()); setPage(1); }}
disabled={dMachinetypeIds.join(",") === preferredMachineIds.join(",")}
>
Default
</Button>
</div> </div>
<div className="d-grid"> </FilterSection>
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div> <FilterSection label="Download filters" defaultOpen={false} badge={
<div> [filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo ? "demo" : ""].filter(Boolean).length
<label className="form-label small text-secondary">Year</label> ? `${[filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo ? "demo" : ""].filter(Boolean).length} active`
<input : undefined
type="number" }>
className="form-control" <div className="d-flex flex-column gap-2">
placeholder="Any" <Form.Select size="sm" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
value={year}
onChange={(e) => { setYear(e.target.value); setPage(1); }}
/>
</div>
<div>
<label className="form-label small text-secondary">DL Language</label>
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{langs.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">DL Machine</label>
<MultiSelectChips
options={machineOptions}
selected={dMachinetypeIds}
onToggle={(id) => {
setDMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
const order = machineOptions.map((item) => item.id);
return order.filter((value) => next.has(value));
});
setPage(1);
}}
/>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">File type</label>
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
<option value="">All file types</option> <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> </Form.Select>
</div> <Form.Select size="sm" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
<div>
<label className="form-label small text-secondary">Scheme</label>
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
<option value="">All schemes</option> <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> </Form.Select>
</div> <Form.Select size="sm" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
<div>
<label className="form-label small text-secondary">Source</label>
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
<option value="">All sources</option> <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> </Form.Select>
</div> <Form.Select size="sm" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
<div>
<label className="form-label small text-secondary">Case</label>
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
<option value="">All cases</option> <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> </Form.Select>
<Form.Check
id="demoCheck"
label="Demo only"
checked={isDemo}
onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }}
/>
</div> </div>
<div className="form-check"> </FilterSection>
<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> <FilterSection label="Sort">
</div> <Form.Select size="sm" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
<div> <option value="year_desc">Newest</option>
<label className="form-label small text-secondary">Sort</label> <option value="year_asc">Oldest</option>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}> <option value="title">Title</option>
<option value="year_desc">Newest</option> <option value="entry_id_desc">Entry ID</option>
<option value="year_asc">Oldest</option> </Form.Select>
<option value="title">Title</option> </FilterSection>
<option value="entry_id_desc">Entry ID</option>
</select> {error && <Alert variant="danger" className="py-1 px-2 small mb-0">{error}</Alert>}
</div> </Form>
{loading && <div className="text-secondary small">Loading...</div>} </FilterSidebar>
</form> )}
</FilterSidebar> >
)} <div className={loading ? "opacity-50" : ""} style={{ transition: "opacity 0.15s" }}>
>
{data && data.items.length === 0 && !loading && ( {data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div> <Alert variant="warning">
No results found.
{hasNonDefaultMachineFilter && (
<span>
{" "}Filtering by{" "}
<strong>
{dMachinetypeIds
.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`)
.join(", ")}
</strong>
{" "}&mdash;{" "}
<Alert.Link onClick={searchAllMachines}>
search all machines
</Alert.Link>?
</span>
)}
</Alert>
)} )}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <Table striped hover className="align-middle">
<thead> <thead>
<tr> <tr>
<th style={{ width: 80 }}>Entry ID</th> <th style={{ width: 80 }}>Entry ID</th>
@@ -411,11 +398,9 @@ export default function ReleasesExplorer({
<EntryLink id={it.entryId} /> <EntryLink id={it.entryId} />
</td> </td>
<td> <td>
<div className="d-flex flex-column gap-1"> <Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`} className="link-underline link-underline-opacity-0">
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`} className="link-underline link-underline-opacity-0"> {it.entryTitle || `Entry #${it.entryId}`}
{it.entryTitle || `Entry #${it.entryId}`} </Link>
</Link>
</div>
</td> </td>
<td> <td>
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}> <Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
@@ -433,40 +418,19 @@ export default function ReleasesExplorer({
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </Table>
</div> </div>
)} )}
</div>
</ExplorerLayout> </ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? page}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link loading={loading}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} buildHref={buildHref}
aria-disabled={!data || data.page <= 1} onPageChange={setPage}
href={prevHref} />
onClick={(e) => {
if (!data || data.page <= 1) return;
e.preventDefault();
setPage((p) => Math.max(1, p - 1));
}}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={nextHref}
onClick={(e) => {
if (!data || data.page >= totalPages) return;
e.preventDefault();
setPage((p) => Math.min(totalPages, p + 1));
}}
>
Next
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,7 +1,10 @@
"use client"; "use client";
import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { Row, Col, Card, Table, Badge, Alert, Button } from "react-bootstrap";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type ReleaseDetailData = { type ReleaseDetailData = {
entry: { entry: {
@@ -43,6 +46,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;
@@ -58,6 +62,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;
@@ -168,7 +173,33 @@ function groupIssueRefs(refs: ReleaseDetailData["magazineRefs"]) {
} }
export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) { export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) {
if (!data) return <div className="alert alert-warning">Not found</div>; 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 <Alert variant="warning">Not found</Alert>;
const magazineGroups = groupMagazineRefs(data.magazineRefs); const magazineGroups = groupMagazineRefs(data.magazineRefs);
const otherReleases = data.entryReleases.filter((r) => r.releaseSeq !== data.release.releaseSeq); const otherReleases = data.entryReleases.filter((r) => r.releaseSeq !== data.release.releaseSeq);
@@ -186,18 +217,17 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
<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>
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/entries/${data.entry.id}`}> <Link href={`/zxdb/entries/${data.entry.id}`} className="text-decoration-none">
{data.entry.title} <Badge bg="secondary">{data.entry.title}</Badge>
</Link> </Link>
</div> </div>
<div className="row g-3 mt-2"> <Row className="g-3 mt-2">
<div className="col-lg-4"> <Col lg={4}>
<div className="card shadow-sm mb-3"> <Card className="shadow-sm mb-3">
<div className="card-body"> <Card.Body>
<h5 className="card-title">Release Summary</h5> <Card.Title>Release Summary</Card.Title>
<div className="table-responsive"> <Table size="sm" striped className="align-middle mb-0">
<table className="table table-sm table-striped align-middle mb-0">
<tbody> <tbody>
<tr> <tr>
<th style={{ width: 160 }}>Entry</th> <th style={{ width: 160 }}>Entry</th>
@@ -262,36 +292,35 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
<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> </Card.Body>
</div> </Card>
</div>
<div className="card shadow-sm"> <Card className="shadow-sm">
<div className="card-body"> <Card.Body>
<h5 className="card-title">Other Releases</h5> <Card.Title>Other Releases</Card.Title>
{otherReleases.length === 0 && <div className="text-secondary">No other releases</div>} {otherReleases.length === 0 && <div className="text-secondary">No other releases</div>}
{otherReleases.length > 0 && ( {otherReleases.length > 0 && (
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">
{otherReleases.map((r) => ( {otherReleases.map((r) => (
<Link <Link
key={r.releaseSeq} key={r.releaseSeq}
className="badge text-bg-light text-decoration-none"
href={`/zxdb/releases/${data.entry.id}/${r.releaseSeq}`} href={`/zxdb/releases/${data.entry.id}/${r.releaseSeq}`}
className="text-decoration-none"
> >
#{r.releaseSeq}{r.year != null ? ` · ${r.year}` : ""} <Badge bg="light" text="dark">#{r.releaseSeq}{r.year != null ? ` · ${r.year}` : ""}</Badge>
</Link> </Link>
))} ))}
</div> </div>
)} )}
</div> </Card.Body>
</div> </Card>
</div> </Col>
<div className="col-lg-8"> <Col lg={8}>
<div className="card shadow-sm mb-3"> <Card className="shadow-sm mb-3">
<div className="card-body"> <Card.Body>
<h5 className="card-title">Places (Magazines)</h5> <Card.Title>Places (Magazines)</Card.Title>
{magazineGroups.length === 0 && <div className="text-secondary">No magazine references</div>} {magazineGroups.length === 0 && <div className="text-secondary">No magazine references</div>}
{magazineGroups.length > 0 && ( {magazineGroups.length > 0 && (
<div className="d-flex flex-column gap-3"> <div className="d-flex flex-column gap-3">
@@ -320,8 +349,7 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
{issueGroup.items.length} reference{issueGroup.items.length === 1 ? "" : "s"} {issueGroup.items.length} reference{issueGroup.items.length === 1 ? "" : "s"}
</div> </div>
</div> </div>
<div className="table-responsive mt-2"> <Table size="sm" striped className="align-middle mt-2">
<table className="table table-sm table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th style={{ width: 80 }}>Page</th> <th style={{ width: 80 }}>Page</th>
@@ -340,193 +368,235 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </Table>
</div>
</div> </div>
))} ))}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </Card.Body>
</div> </Card>
<div className="card shadow-sm mb-3"> <Card className="shadow-sm mb-3">
<div className="card-body"> <Card.Body>
<h5 className="card-title">Downloads</h5> <Card.Title>Downloads</Card.Title>
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>} {groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{data.downloads.length > 0 && ( {groupedDownloads.map(([type, items]) => (
<div className="table-responsive"> <div key={type} className="mb-4">
<table className="table table-sm table-striped align-middle"> <h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<thead> <Table size="sm" striped className="align-middle">
<tr> <thead>
<th>Type</th> <tr>
<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://");
return ( const fileName = d.link.split("/").pop() || "file";
<tr key={d.id}> const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
<td><span className="badge text-bg-secondary">{d.type.name}</span></td> return (
<td> <tr key={d.id}>
{isHttp ? ( <td>
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a> <div className="d-flex flex-column gap-1">
) : ( <div className="d-flex align-items-center gap-2">
<span>{d.link}</span> {isHttp ? (
)} <a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
</td> ) : (
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td> <span className="text-break small">{d.link}</span>
<td><code>{d.md5 ?? "-"}</code></td> )}
<td> {canPreview && (
<div className="d-flex gap-1 flex-wrap"> <button
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null} className="btn btn-xs btn-outline-info py-0 px-1"
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null} style={{ fontSize: "0.6rem" }}
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null} onClick={() => setViewer({ url: d.localLink!, title: fileName })}
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null} >
</div> Preview
</td> </button>
<td> )}
<div className="d-flex gap-2 flex-wrap align-items-center"> </div>
{d.language.name ? ( {d.localLink && (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link> <a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
) : null} Local Mirror
{d.machinetype.name ? ( </a>
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link> )}
) : null} </div>
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null} </td>
</div> <td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
</td> <td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
<td>{d.comments ?? ""}</td> <td>
</tr> <div className="d-flex gap-1 flex-wrap">
); {d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
})} {d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
</tbody> {d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
</table> {d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{d.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
) : null}
{d.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
) : null}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
</div>
</td>
<td className="small">{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</Table>
</div> </div>
)} ))}
</div> </Card.Body>
</div> </Card>
<div className="card shadow-sm mb-3"> <Card className="shadow-sm mb-3">
<div className="card-body"> <Card.Body>
<h5 className="card-title">Scraps / Media</h5> <Card.Title>Scraps / Media</Card.Title>
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>} {groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>}
{data.scraps.length > 0 && ( {groupedScraps.map(([type, items]) => (
<div className="table-responsive"> <div key={type} className="mb-4">
<table className="table table-sm table-striped align-middle"> <h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<thead> <Table size="sm" striped className="align-middle">
<tr> <thead>
<th>Type</th> <tr>
<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://");
return ( const fileName = s.link?.split("/").pop() || "file";
<tr key={s.id}> const canPreview = s.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
<td><span className="badge text-bg-secondary">{s.type.name}</span></td> return (
<td> <tr key={s.id}>
{s.link ? ( <td>
isHttp ? ( <div className="d-flex flex-column gap-1">
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a> <div className="d-flex align-items-center gap-2">
) : ( {s.link ? (
<span>{s.link}</span> isHttp ? (
) <a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
) : ( ) : (
<span className="text-secondary">-</span> <span className="text-break small">{s.link}</span>
)} )
</td> ) : (
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td> <span className="text-secondary">-</span>
<td> )}
<div className="d-flex gap-1 flex-wrap"> {canPreview && (
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null} <button
{s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null} className="btn btn-xs btn-outline-info py-0 px-1"
{s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null} style={{ fontSize: "0.6rem" }}
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null} onClick={() => setViewer({ url: s.localLink!, title: fileName })}
</div> >
</td> Preview
<td> </button>
<div className="d-flex gap-2 flex-wrap align-items-center"> )}
{s.language.name ? ( </div>
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link> {s.localLink && (
) : null} <a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
{s.machinetype.name ? ( Local Mirror
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link> </a>
) : null} )}
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null} </div>
</div> </td>
</td> <td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
<td>{s.rationale}</td> <td>
</tr> <div className="d-flex gap-1 flex-wrap">
); {s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
})} {s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
</tbody> {s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
</table> {s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{s.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link>
) : null}
{s.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link>
) : null}
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
</div>
</td>
<td className="small">{s.rationale}</td>
</tr>
);
})}
</tbody>
</Table>
</div> </div>
)} ))}
</div> </Card.Body>
</div> </Card>
<div className="card shadow-sm mb-3"> <Card className="shadow-sm mb-3">
<div className="card-body"> <Card.Body>
<h5 className="card-title">Issue Files</h5> <Card.Title>Issue Files</Card.Title>
{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"> <Table size="sm" striped className="align-middle">
<table className="table table-sm table-striped align-middle"> <thead>
<thead> <tr>
<tr> <th>Type</th>
<th>Type</th> <th>Link</th>
<th>Link</th> <th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 120 }} className="text-end">Size</th> <th style={{ width: 240 }}>MD5</th>
<th style={{ width: 240 }}>MD5</th> <th>Comments</th>
<th>Comments</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {data.files.map((f) => {
{data.files.map((f) => { const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://"); return (
return ( <tr key={f.id}>
<tr key={f.id}> <td><Badge bg="secondary">{f.type.name}</Badge></td>
<td><span className="badge text-bg-secondary">{f.type.name}</span></td> <td>
<td> {isHttp ? (
{isHttp ? ( <a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a> ) : (
) : ( <span>{f.link}</span>
<span>{f.link}</span> )}
)} </td>
</td> <td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td> <td><code>{f.md5 ?? "-"}</code></td>
<td><code>{f.md5 ?? "-"}</code></td> <td>{f.comments ?? ""}</td>
<td>{f.comments ?? ""}</td> </tr>
</tr> );
); })}
})} </tbody>
</tbody> </Table>
</table>
</div>
)} )}
</div> </Card.Body>
</div> </Card>
</div> </Col>
</div> </Row>
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/releases/${data.entry.id}/${data.release.releaseSeq}`}>Permalink</Link> <Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/releases/${data.entry.id}/${data.release.releaseSeq}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb/releases">Back to Releases</Link> <Link className="btn btn-sm btn-outline-primary" href="/zxdb/releases">Back to Releases</Link>
</div> </div>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,9 @@
import { Spinner } from "react-bootstrap";
export default function Loading() {
return (
<div className="d-flex justify-content-center align-items-center py-5">
<Spinner animation="border" variant="primary" />
</div>
);
}

View File

@@ -1,16 +1,20 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ReleaseDetailClient from "./ReleaseDetail"; import ReleaseDetailClient from "./ReleaseDetail";
import { getReleaseDetail } from "@/server/repo/zxdb"; import { getReleaseDetail } from "@/server/repo";
export const metadata = {
title: "ZXDB Release",
};
export const revalidate = 3600; export const revalidate = 3600;
export async function generateMetadata({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }): Promise<Metadata> {
const { entryId, releaseSeq } = await params;
const data = await getReleaseDetail(Number(entryId), Number(releaseSeq));
if (!data) return { title: "Release Not Found | ZXDB" };
return { title: `${data.entry.title} #${releaseSeq} | ZXDB Release` };
}
export default async function Page({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }) { export default async function Page({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }) {
const { entryId, releaseSeq } = await params; const { entryId, releaseSeq } = await params;
const entryIdNum = Number(entryId); const data = await getReleaseDetail(Number(entryId), Number(releaseSeq));
const releaseSeqNum = Number(releaseSeq); if (!data) notFound();
const data = await getReleaseDetail(entryIdNum, releaseSeqNum);
return <ReleaseDetailClient data={data} />; return <ReleaseDetailClient data={data} />;
} }

View File

@@ -1,5 +1,7 @@
import ReleasesExplorer from "./ReleasesExplorer"; import ReleasesExplorer from "./ReleasesExplorer";
import { listCasetypes, listFiletypes, listLanguages, listMachinetypes, listSchemetypes, listSourcetypes, searchReleases } from "@/server/repo/zxdb"; import { listCasetypes, listFiletypes, listLanguages, listMachinetypes, listSchemetypes, listSourcetypes, searchReleases } from "@/server/repo";
import { parseIdList } from "@/utils/params";
import { serialize } from "@/utils/serialize";
export const metadata = { export const metadata = {
title: "ZXDB Releases", title: "ZXDB Releases",
@@ -7,16 +9,6 @@ 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);
@@ -47,20 +39,17 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
listCasetypes(), listCasetypes(),
]); ]);
// Ensure the object passed to a Client Component is a plain JSON value
const initialPlain = JSON.parse(JSON.stringify(initial));
return ( return (
<ReleasesExplorer <ReleasesExplorer
initial={initialPlain} initial={serialize(initial)}
initialLists={{ initialLists={serialize({
languages: JSON.parse(JSON.stringify(langs)), languages: langs,
machinetypes: JSON.parse(JSON.stringify(machines)), machinetypes: machines,
filetypes: JSON.parse(JSON.stringify(filetypes)), filetypes,
schemetypes: JSON.parse(JSON.stringify(schemes)), schemetypes: schemes,
sourcetypes: JSON.parse(JSON.stringify(sources)), sourcetypes: sources,
casetypes: JSON.parse(JSON.stringify(cases)), casetypes: cases,
}} })}
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }} initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
initialUrlHasParams={hasParams} initialUrlHasParams={hasParams}
/> />

View File

@@ -0,0 +1,92 @@
"use client";
import { useEffect, 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`;
useEffect(() => {
if (isText) {
setLoading(true);
setError(null);
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);
}
}, [viewUrl, isText]);
return (
<Modal show size="xl" onHide={onClose} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0 bg-dark text-light" style={{ minHeight: "300px" }}>
{loading && (
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: "300px" }}>
<Spinner animation="border" variant="light" />
</div>
)}
{error && (
<div className="p-4 text-center">
<p className="text-danger">{error}</p>
</div>
)}
{!loading && !error && (
<>
{isText && (
<pre className="p-3 m-0" style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: "0.9rem", color: "#ccc" }}>
{content}
</pre>
)}
{isImage && (
<div className="text-center p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={viewUrl} alt={title} className="img-fluid" style={{ maxHeight: "80vh" }} />
</div>
)}
{isPdf && (
<iframe src={viewUrl} style={{ width: "100%", height: "80vh", border: "none" }} title={title} />
)}
{!isText && !isImage && !isPdf && (
<div className="p-4 text-center">
<p>Preview not available for this file type.</p>
<a href={url} className="btn btn-primary">Download File</a>
</div>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Close</Button>
<a href={url} className="btn btn-success" download>Download</a>
</Modal.Footer>
</Modal>
);
}

View File

@@ -8,13 +8,13 @@ export default function NavbarClient() {
return ( return (
<Navbar expand="lg" bg="primary" data-bs-theme="dark" sticky="top" className="navbar"> <Navbar expand="lg" bg="primary" data-bs-theme="dark" sticky="top" className="navbar">
<Container fluid> <Container fluid>
<Link className="navbar-brand" href="/">SpecNext Explorer</Link> <Navbar.Brand as={Link} href="/">SpecNext Explorer</Navbar.Brand>
<Navbar.Toggle aria-controls="navbarSupportedContent" /> <Navbar.Toggle aria-controls="navbarSupportedContent" />
<Navbar.Collapse id="navbarSupportedContent"> <Navbar.Collapse id="navbarSupportedContent">
<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> <Nav.Link as={Link} href="/">Home</Nav.Link>
<Link className="nav-link" href="/registers">Registers</Link> <Nav.Link as={Link} href="/registers">Registers</Nav.Link>
<Link className="nav-link" href="/zxdb">ZXDB</Link> <Nav.Link as={Link} href="/zxdb">ZXDB</Nav.Link>
</Nav> </Nav>
<ThemeDropdown /> <ThemeDropdown />

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import * as Icon from "react-bootstrap-icons"; import { SunFill, MoonStarsFill, CircleHalf, Check2 } from "react-bootstrap-icons";
import { Nav, Dropdown } from "react-bootstrap"; import { Nav, Dropdown } from "react-bootstrap";
type Theme = "light" | "dark" | "auto"; type Theme = "light" | "dark" | "auto";
@@ -13,7 +13,7 @@ const getCookie = (name: string) => {
}; };
const setCookie = (name: string, value: string) => { const setCookie = (name: string, value: string) => {
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax; Domain=specnext.dev`; document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax`;
}; };
const prefersDark = () => const prefersDark = () =>
@@ -62,12 +62,12 @@ export default function ThemeDropdown() {
const isActive = (t: Theme) => theme === t; const isActive = (t: Theme) => theme === t;
const ToggleIcon = !mounted const ToggleIcon = !mounted
? Icon.CircleHalf ? CircleHalf
: theme === "dark" : theme === "dark"
? Icon.MoonStarsFill ? MoonStarsFill
: theme === "light" : theme === "light"
? Icon.SunFill ? SunFill
: Icon.CircleHalf; : CircleHalf;
return ( return (
<Nav className="ms-md-auto"> <Nav className="ms-md-auto">
@@ -78,19 +78,19 @@ export default function ThemeDropdown() {
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu aria-labelledby="bd-theme-text"> <Dropdown.Menu aria-labelledby="bd-theme-text">
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("light")} onClick={() => choose("light")}> <Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("light")} onClick={() => choose("light")}>
<Icon.SunFill /> <SunFill />
<span className="ms-2">Light</span> <span className="ms-2">Light</span>
{isActive("light") && <Icon.Check2 className="ms-auto" aria-hidden="true" />} {isActive("light") && <Check2 className="ms-auto" aria-hidden="true" />}
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("dark")} onClick={() => choose("dark")}> <Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("dark")} onClick={() => choose("dark")}>
<Icon.MoonStarsFill /> <MoonStarsFill />
<span className="ms-2">Dark</span> <span className="ms-2">Dark</span>
{isActive("dark") && <Icon.Check2 className="ms-auto" aria-hidden="true" />} {isActive("dark") && <Check2 className="ms-auto" aria-hidden="true" />}
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("auto")} onClick={() => choose("auto")}> <Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("auto")} onClick={() => choose("auto")}>
<Icon.CircleHalf /> <CircleHalf />
<span className="ms-2">Auto</span> <span className="ms-2">Auto</span>
{isActive("auto") && <Icon.Check2 className="ms-auto" aria-hidden="true" />} {isActive("auto") && <Check2 className="ms-auto" aria-hidden="true" />}
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>

View File

@@ -0,0 +1,54 @@
"use client";
import { ReactNode, useState } from "react";
import { Collapse } from "react-bootstrap";
import { ChevronDown } from "react-bootstrap-icons";
type FilterSectionProps = {
label: string;
badge?: string;
defaultOpen?: boolean;
children: ReactNode;
};
export default function FilterSection({
label,
badge,
defaultOpen = true,
children,
}: FilterSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-bottom pb-2">
<button
type="button"
className="btn btn-sm w-100 d-flex align-items-center justify-content-between p-0 text-start"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="form-label small text-secondary mb-0 fw-semibold">{label}</span>
<span className="d-flex align-items-center gap-1">
{!open && badge && (
<span className="badge text-bg-primary rounded-pill" style={{ fontSize: "0.7rem" }}>
{badge}
</span>
)}
<ChevronDown
size={10}
className="text-secondary"
style={{
transition: "transform 0.2s",
transform: open ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</span>
</button>
<Collapse in={open}>
<div>
<div className="mt-1">{children}</div>
</div>
</Collapse>
</div>
);
}

View File

@@ -1,13 +1,31 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Card, Button } from "react-bootstrap";
type FilterSidebarProps = { type FilterSidebarProps = {
children: ReactNode; children: ReactNode;
onReset?: () => void;
loading?: boolean;
}; };
export default function FilterSidebar({ children }: FilterSidebarProps) { export default function FilterSidebar({ children, onReset, loading }: FilterSidebarProps) {
return ( return (
<div className="card shadow-sm"> <Card className="shadow-sm">
<div className="card-body">{children}</div> <Card.Body className="d-flex flex-column gap-2">
</div> {children}
{onReset && (
<div className="border-top pt-2 mt-1">
<Button
variant="outline-secondary"
size="sm"
className="w-100"
onClick={onReset}
disabled={loading}
>
Reset all filters
</Button>
</div>
)}
</Card.Body>
</Card>
); );
} }

View File

@@ -1,3 +1,7 @@
"use client";
import { useState } from "react";
type ChipOption<T extends number | string> = { type ChipOption<T extends number | string> = {
id: T; id: T;
label: string; label: string;
@@ -8,6 +12,10 @@ type MultiSelectChipsProps<T extends number | string> = {
selected: T[]; selected: T[];
onToggle: (id: T) => void; onToggle: (id: T) => void;
size?: "sm" | "md"; size?: "sm" | "md";
/** When set, chips start collapsed showing just selected count + names */
collapsible?: boolean;
/** Max selected labels to show in collapsed summary before truncating */
collapsedMax?: number;
}; };
export default function MultiSelectChips<T extends number | string>({ export default function MultiSelectChips<T extends number | string>({
@@ -15,23 +23,60 @@ export default function MultiSelectChips<T extends number | string>({
selected, selected,
onToggle, onToggle,
size = "sm", size = "sm",
collapsible = false,
collapsedMax = 3,
}: MultiSelectChipsProps<T>) { }: MultiSelectChipsProps<T>) {
const [expanded, setExpanded] = useState(!collapsible);
const btnSize = size === "sm" ? "btn-sm" : ""; const btnSize = size === "sm" ? "btn-sm" : "";
if (!expanded) {
const selectedLabels = selected
.map((id) => options.find((o) => o.id === id)?.label)
.filter(Boolean) as string[];
const shown = selectedLabels.slice(0, collapsedMax);
const extra = selectedLabels.length - shown.length;
const summary = shown.length
? shown.join(", ") + (extra > 0 ? ` +${extra}` : "")
: "None";
return (
<button
type="button"
className="btn btn-sm btn-outline-secondary w-100 text-start d-flex justify-content-between align-items-center"
onClick={() => setExpanded(true)}
>
<span className="text-truncate">{summary}</span>
<span className="badge text-bg-primary rounded-pill ms-2">{selected.length}</span>
</button>
);
}
return ( return (
<div className="d-flex flex-wrap gap-2"> <div>
{options.map((option) => { <div className="d-flex flex-wrap gap-1">
const active = selected.includes(option.id); {options.map((option) => {
return ( const active = selected.includes(option.id);
<button return (
key={String(option.id)} <button
type="button" key={String(option.id)}
className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`} type="button"
onClick={() => onToggle(option.id)} className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`}
> onClick={() => onToggle(option.id)}
{option.label} >
</button> {option.label}
); </button>
})} );
})}
</div>
{collapsible && (
<button
type="button"
className="btn btn-link btn-sm p-0 mt-1 text-secondary"
onClick={() => setExpanded(false)}
>
Collapse
</button>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,67 @@
"use client";
import { useMemo } from "react";
import { Button, Spinner } from "react-bootstrap";
import { ChevronLeft, ChevronRight } from "react-bootstrap-icons";
type PaginationProps = {
page: number;
totalPages: number;
loading?: boolean;
/** Build href for a given page number (for SSR/link fallback) */
buildHref: (p: number) => string;
onPageChange: (p: number) => void;
};
export default function Pagination({
page,
totalPages,
loading,
buildHref,
onPageChange,
}: PaginationProps) {
const canPrev = page > 1;
const canNext = page < totalPages;
const prevHref = useMemo(() => buildHref(Math.max(1, page - 1)), [buildHref, page]);
const nextHref = useMemo(() => buildHref(Math.min(totalPages, page + 1)), [buildHref, page, totalPages]);
return (
<div className="d-flex align-items-center gap-2 mt-4">
<span className={loading ? "text-secondary" : ""}>
Page {page} / {totalPages}
</span>
{loading && (
<Spinner animation="border" size="sm" variant="secondary" role="status" />
)}
<div className="ms-auto d-flex gap-2">
<Button
as="a"
variant="outline-secondary"
href={prevHref}
disabled={!canPrev}
onClick={(e: React.MouseEvent) => {
if (!canPrev) return;
e.preventDefault();
onPageChange(page - 1);
}}
>
<ChevronLeft size={14} /> Prev
</Button>
<Button
as="a"
variant="outline-secondary"
href={nextHref}
disabled={!canNext}
onClick={(e: React.MouseEvent) => {
if (!canNext) return;
e.preventDefault();
onPageChange(page + 1);
}}
>
Next <ChevronRight size={14} />
</Button>
</div>
</div>
);
}

View File

@@ -14,6 +14,10 @@ const serverSchema = z.object({
ZXDB_FILE_PREFIX: z.string().optional(), ZXDB_FILE_PREFIX: z.string().optional(),
WOS_FILE_PREFIX: z.string().optional(), WOS_FILE_PREFIX: z.string().optional(),
// Local file paths for mirroring
ZXDB_LOCAL_FILEPATH: z.string().optional(),
WOS_LOCAL_FILEPATH: z.string().optional(),
// OIDC Configuration // OIDC Configuration
OIDC_PROVIDER_URL: z.string().url().optional(), OIDC_PROVIDER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(),

View File

@@ -0,0 +1,82 @@
"use client";
import { useCallback, useRef, useState } from "react";
import type { PagedResult } from "@/types/zxdb";
/**
* Manages API search fetching with automatic request cancellation
* to prevent race conditions from rapid filter/page changes.
* Keeps previous results visible while a new request is in flight.
*
* @param onExtra - optional callback to capture extra fields from the response
* (e.g., facets) that sit alongside the standard paged fields.
*/
export default function useSearchFetch<T>(
endpoint: string,
initialData: PagedResult<T> | null = null,
onExtra?: (json: Record<string, unknown>) => void,
) {
const [data, setData] = useState<PagedResult<T> | null>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchIdRef = useRef(0);
const onExtraRef = useRef(onExtra);
onExtraRef.current = onExtra;
const fetch_ = useCallback(
async (params: URLSearchParams) => {
// Cancel any in-flight request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const id = ++fetchIdRef.current;
setLoading(true);
setError(null);
try {
const res = await globalThis.fetch(
`${endpoint}?${params.toString()}`,
{ signal: controller.signal },
);
if (!res.ok) throw new Error(`Search failed (${res.status})`);
const json = await res.json();
// Only apply if this is still the latest request
if (id === fetchIdRef.current) {
setData({
items: json.items,
page: json.page,
pageSize: json.pageSize,
total: json.total,
});
onExtraRef.current?.(json);
}
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") return;
if (id === fetchIdRef.current) {
const msg = e instanceof Error ? e.message : "Search failed";
console.error(msg);
setError(msg);
setData({ items: [] as T[], page: 1, pageSize: 20, total: 0 });
}
} finally {
if (id === fetchIdRef.current) {
setLoading(false);
}
}
},
[endpoint],
);
// Allow syncing SSR data without a fetch
const syncData = useCallback((d: PagedResult<T>) => {
abortRef.current?.abort();
setData(d);
setLoading(false);
setError(null);
}, []);
return { data, loading, error, fetch: fetch_, syncData };
}

View File

@@ -1,12 +0,0 @@
import { NextResponse } from 'next/server'
export function middleware(request) {
const { method, nextUrl } = request
// Filter out internal Next.js assets if desired
if (!nextUrl.pathname.startsWith('/_next')) {
console.log(`${method} ${nextUrl.pathname}`)
}
return NextResponse.next()
}

11
src/middleware.ts Normal file
View File

@@ -0,0 +1,11 @@
import { NextResponse, NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
if (process.env.NODE_ENV === 'development') {
const { method, nextUrl } = request;
if (!nextUrl.pathname.startsWith('/_next')) {
console.log(`${method} ${nextUrl.pathname}`);
}
}
return NextResponse.next();
}

3
src/server/repo/index.ts Normal file
View File

@@ -0,0 +1,3 @@
// Barrel re-export — enables import from "@/server/repo" and
// sets up for incremental per-domain splitting.
export * from "./zxdb";

View File

@@ -1,5 +1,8 @@
import { and, desc, eq, sql, asc } from "drizzle-orm"; import { and, desc, eq, sql, asc } from "drizzle-orm";
import { cache } from "react"; import { cache } from "react";
import fs from "fs";
import path from "path";
import { env } from "@/env";
// import { alias } from "drizzle-orm/mysql-core"; // import { alias } from "drizzle-orm/mysql-core";
import { db } from "@/server/db"; import { db } from "@/server/db";
import { import {
@@ -52,6 +55,8 @@ import {
magrefs, magrefs,
searchByMagrefs, searchByMagrefs,
referencetypes, referencetypes,
countries,
softwareHashes,
} from "@/server/schema/zxdb"; } from "@/server/schema/zxdb";
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins"; export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
@@ -64,6 +69,8 @@ export interface SearchParams {
genreId?: number; genreId?: number;
languageId?: string; languageId?: string;
machinetypeId?: number | number[]; machinetypeId?: number | number[];
// Year filter
year?: number;
// Sorting // Sorting
sort?: "title" | "id_desc"; sort?: "title" | "id_desc";
// Search scope (defaults to titles only) // Search scope (defaults to titles only)
@@ -105,6 +112,35 @@ export interface EntryFacets {
}; };
} }
/**
* Resolves a local link for a given file link if mirroring is enabled and the file exists.
*/
function resolveLocalLink(fileLink: string): string | null {
let localPath: string | null = null;
let source: "zxdb" | "wos" | null = null;
let subPath: string | null = null;
const zxdbPrefix = env.ZXDB_FILE_PREFIX || "";
const wosPrefix = env.WOS_FILE_PREFIX || "";
if (fileLink.startsWith(zxdbPrefix) && env.ZXDB_LOCAL_FILEPATH) {
subPath = fileLink.slice(zxdbPrefix.replace(/\/$/, "").length);
localPath = path.join(env.ZXDB_LOCAL_FILEPATH, subPath);
source = "zxdb";
} else if (fileLink.startsWith(wosPrefix) && env.WOS_LOCAL_FILEPATH) {
subPath = fileLink.slice(wosPrefix.replace(/\/$/, "").length);
localPath = path.join(env.WOS_LOCAL_FILEPATH, subPath);
source = "wos";
}
if (localPath && fs.existsSync(localPath) && source && subPath) {
// Return an application-relative URL instead of the absolute filesystem path
return `/api/zxdb/download?source=${source}&path=${encodeURIComponent(subPath)}`;
}
return null;
}
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) { function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
const parts: Array<ReturnType<typeof sql>> = [ const parts: Array<ReturnType<typeof sql>> = [
sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern}`, sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern}`,
@@ -206,6 +242,9 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const ids = params.machinetypeId.map((id) => sql`${id}`); const ids = params.machinetypeId.map((id) => sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`); whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
} }
if (typeof params.year === "number") {
whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
}
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined; const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
@@ -250,9 +289,30 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
if (scope !== "title") { if (scope !== "title") {
try { try {
const union = buildEntrySearchUnion(pattern, scope); const union = buildEntrySearchUnion(pattern, scope);
const whereClauses: Array<ReturnType<typeof sql>> = [
sql`${entries.id} in (select entry_id from (${union}) as matches)`
];
if (typeof params.genreId === "number") {
whereClauses.push(eq(entries.genretypeId, params.genreId));
}
if (typeof params.languageId === "string") {
whereClauses.push(eq(entries.languageId, params.languageId));
}
if (typeof params.machinetypeId === "number") {
whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
} else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
const ids = params.machinetypeId.map((id) => sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
}
if (typeof params.year === "number") {
whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
}
const whereExpr = and(...whereClauses);
const countRows = await db.execute(sql` const countRows = await db.execute(sql`
select count(distinct entry_id) as total select count(distinct id) as total
from (${union}) as matches from entries
where ${whereExpr}
`); `);
type CountRow = { total: number | string }; type CountRow = { total: number | string };
const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0); const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0);
@@ -273,7 +333,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId)) .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId)) .leftJoin(languages, eq(languages.id, entries.languageId))
.where(sql`${entries.id} in (select entry_id from (${union}) as matches)`) .where(whereExpr)
.groupBy(entries.id) .groupBy(entries.id)
.orderBy( .orderBy(
...(preferMachineOrder ? [preferMachineOrder] : []), ...(preferMachineOrder ? [preferMachineOrder] : []),
@@ -289,10 +349,31 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
} }
// Count matches via helper table // Count matches via helper table
const whereClauses: Array<ReturnType<typeof sql>> = [
sql`lower(${searchByTitles.entryTitle}) like ${pattern}`
];
if (typeof params.genreId === "number") {
whereClauses.push(eq(entries.genretypeId, params.genreId));
}
if (typeof params.languageId === "string") {
whereClauses.push(eq(entries.languageId, params.languageId));
}
if (typeof params.machinetypeId === "number") {
whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
} else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
const ids = params.machinetypeId.map((id) => sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
}
if (typeof params.year === "number") {
whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
}
const whereExpr = and(...whereClauses);
const countRows = await db const countRows = await db
.select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` }) .select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
.from(searchByTitles) .from(searchByTitles)
.where(sql`lower(${searchByTitles.entryTitle}) like ${pattern}`); .innerJoin(entries, eq(entries.id, searchByTitles.entryId))
.where(whereExpr);
const total = Number(countRows[0]?.total ?? 0); const total = Number(countRows[0]?.total ?? 0);
@@ -314,7 +395,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId)) .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId)) .leftJoin(languages, eq(languages.id, entries.languageId))
.where(sql`lower(${searchByTitles.entryTitle}) like ${pattern}`) .where(whereExpr)
.groupBy(entries.id) .groupBy(entries.id)
.orderBy( .orderBy(
...(preferMachineOrder ? [preferMachineOrder] : []), ...(preferMachineOrder ? [preferMachineOrder] : []),
@@ -431,6 +512,7 @@ export interface EntryDetail {
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;
@@ -453,11 +535,32 @@ export interface EntryDetail {
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 surfaced on the entry detail page // Additional relationships surfaced on the entry detail page
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 async function getEntryById(id: number): Promise<EntryDetail | null> { export async function getEntryById(id: number): Promise<EntryDetail | null> {
@@ -642,6 +745,7 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null }, source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null },
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null }, case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
year: d.year != null ? Number(d.year) : null, year: d.year != null ? Number(d.year) : null,
localLink: resolveLocalLink(d.link),
})), })),
})); }));
@@ -731,6 +835,24 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
notetypeName: string | null; notetypeName: string | null;
text: string; text: string;
}[] = []; }[] = [];
let magazineRefRows: {
id: number;
issueId: number;
magazineId: number | null;
magazineName: string | null;
referencetypeId: number;
referencetypeName: string | null;
page: number;
isOriginal: number;
scoreGroup: string;
issueDateYear: number | null;
issueDateMonth: number | null;
issueDateDay: number | null;
issueVolume: number | null;
issueNumber: number | null;
issueSpecial: string | null;
issueSupplement: string | null;
}[] = [];
try { try {
aliasRows = await db aliasRows = await db
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title }) .select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
@@ -907,6 +1029,43 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
noteRows = rows as typeof noteRows; noteRows = rows as typeof noteRows;
} catch {} } catch {}
try {
const rows = await db
.select({
id: magrefs.id,
issueId: magrefs.issueId,
magazineId: magazines.id,
magazineName: magazines.name,
referencetypeId: magrefs.referencetypeId,
referencetypeName: referencetypes.name,
page: magrefs.page,
isOriginal: magrefs.isOriginal,
scoreGroup: magrefs.scoreGroup,
issueDateYear: issues.dateYear,
issueDateMonth: issues.dateMonth,
issueDateDay: issues.dateDay,
issueVolume: issues.volume,
issueNumber: issues.number,
issueSpecial: issues.special,
issueSupplement: issues.supplement,
})
.from(searchByMagrefs)
.innerJoin(magrefs, eq(magrefs.id, searchByMagrefs.magrefId))
.leftJoin(issues, eq(issues.id, magrefs.issueId))
.leftJoin(magazines, eq(magazines.id, issues.magazineId))
.leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
.where(eq(searchByMagrefs.entryId, id))
.orderBy(
asc(magazines.name),
asc(issues.dateYear),
asc(issues.dateMonth),
asc(issues.id),
asc(magrefs.page),
asc(magrefs.id)
);
magazineRefRows = rows as typeof magazineRefRows;
} catch {}
return { return {
id: base.id, id: base.id,
title: base.title, title: base.title,
@@ -1025,9 +1184,30 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null }, case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
year: d.year != null ? Number(d.year) : null, year: d.year != null ? Number(d.year) : null,
releaseSeq: Number(d.releaseSeq), releaseSeq: Number(d.releaseSeq),
localLink: resolveLocalLink(d.link),
})), })),
aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })), aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })),
webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })), webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })),
magazineRefs: magazineRefRows.map((m) => ({
id: m.id,
issueId: Number(m.issueId),
magazineId: m.magazineId != null ? Number(m.magazineId) : null,
magazineName: m.magazineName ?? null,
referencetypeId: Number(m.referencetypeId),
referencetypeName: m.referencetypeName ?? null,
page: Number(m.page),
isOriginal: Number(m.isOriginal),
scoreGroup: m.scoreGroup ?? "",
issue: {
dateYear: m.issueDateYear != null ? Number(m.issueDateYear) : null,
dateMonth: m.issueDateMonth != null ? Number(m.issueDateMonth) : null,
dateDay: m.issueDateDay != null ? Number(m.issueDateDay) : null,
volume: m.issueVolume != null ? Number(m.issueVolume) : null,
number: m.issueNumber != null ? Number(m.issueNumber) : null,
special: m.issueSpecial ?? null,
supplement: m.issueSupplement ?? null,
},
})),
}; };
} }
@@ -1035,6 +1215,12 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
export interface LabelDetail extends LabelSummary { export interface LabelDetail extends LabelSummary {
labeltypeName: string | null; labeltypeName: string | null;
countryId: string | null;
countryName: string | null;
country2Id: string | null;
country2Name: string | null;
linkWikipedia: string | null;
linkSite: string | null;
permissions: { permissions: {
website: { id: number; name: string; link?: string | null }; website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null }; type: { id: string; name: string | null };
@@ -1099,9 +1285,17 @@ export async function getLabelById(id: number): Promise<LabelDetail | null> {
name: labels.name, name: labels.name,
labeltypeId: labels.labeltypeId, labeltypeId: labels.labeltypeId,
labeltypeName: labeltypes.name, labeltypeName: labeltypes.name,
countryId: labels.countryId,
countryName: sql<string>`c1.text`,
country2Id: labels.country2Id,
country2Name: sql<string>`c2.text`,
linkWikipedia: labels.linkWikipedia,
linkSite: labels.linkSite,
}) })
.from(labels) .from(labels)
.leftJoin(labeltypes, eq(labeltypes.id, labels.labeltypeId)) .leftJoin(labeltypes, eq(labeltypes.id, labels.labeltypeId))
.leftJoin(sql`${countries} c1`, eq(sql`c1.id`, labels.countryId))
.leftJoin(sql`${countries} c2`, eq(sql`c2.id`, labels.country2Id))
.where(eq(labels.id, id)) .where(eq(labels.id, id))
.limit(1); .limit(1);
const base = rows[0]; const base = rows[0];
@@ -1165,6 +1359,12 @@ export async function getLabelById(id: number): Promise<LabelDetail | null> {
name: base.name, name: base.name,
labeltypeId: base.labeltypeId, labeltypeId: base.labeltypeId,
labeltypeName: base.labeltypeName ?? null, labeltypeName: base.labeltypeName ?? null,
countryId: base.countryId ?? null,
countryName: base.countryName ?? null,
country2Id: base.country2Id ?? null,
country2Name: base.country2Name ?? null,
linkWikipedia: base.linkWikipedia ?? null,
linkSite: base.linkSite ?? null,
permissions: permissionRows.map((p) => ({ permissions: permissionRows.map((p) => ({
website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null }, website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null },
type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null }, type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null },
@@ -1941,6 +2141,7 @@ export interface ReleaseDetail {
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;
@@ -1956,6 +2157,7 @@ export interface ReleaseDetail {
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;
@@ -2208,6 +2410,7 @@ export async function getReleaseDetail(entryId: number, releaseSeq: number): Pro
source: { id: d.sourceId ?? null, name: d.sourceName ?? null }, source: { id: d.sourceId ?? null, name: d.sourceName ?? null },
case: { id: d.caseId ?? null, name: d.caseName ?? null }, case: { id: d.caseId ?? null, name: d.caseName ?? null },
year: d.year != null ? Number(d.year) : null, year: d.year != null ? Number(d.year) : null,
localLink: resolveLocalLink(d.link),
})), })),
scraps: (scrapRows as ScrapRow[]).map((s) => ({ scraps: (scrapRows as ScrapRow[]).map((s) => ({
id: Number(s.id), id: Number(s.id),
@@ -2223,6 +2426,7 @@ export async function getReleaseDetail(entryId: number, releaseSeq: number): Pro
source: { id: s.sourceId ?? null, name: s.sourceName ?? null }, source: { id: s.sourceId ?? null, name: s.sourceName ?? null },
case: { id: s.caseId ?? null, name: s.caseName ?? null }, case: { id: s.caseId ?? null, name: s.caseName ?? null },
year: s.year != null ? Number(s.year) : null, year: s.year != null ? Number(s.year) : null,
localLink: s.link ? resolveLocalLink(s.link) : null,
})), })),
files: fileRows.map((f) => ({ files: fileRows.map((f) => ({
id: f.id, id: f.id,
@@ -2418,3 +2622,86 @@ export async function getIssue(id: number): Promise<IssueDetail | null> {
})), })),
}; };
} }
// ----- Tape identification via software_hashes -----
export type TapeMatch = {
downloadId: number;
entryId: number;
entryTitle: string;
innerPath: string;
md5: string;
crc32: string;
sizeBytes: number;
machinetype: string | null;
genre: string | null;
releaseYear: number | null;
authors: string[];
downloadLink: string;
};
export async function lookupByMd5(md5: string): Promise<TapeMatch[]> {
const rows = await db
.select({
downloadId: softwareHashes.downloadId,
entryId: downloads.entryId,
entryTitle: entries.title,
innerPath: softwareHashes.innerPath,
md5: softwareHashes.md5,
crc32: softwareHashes.crc32,
sizeBytes: softwareHashes.sizeBytes,
machinetypeName: machinetypes.name,
genreName: genretypes.name,
releaseYear: releases.releaseYear,
downloadLink: downloads.fileLink,
})
.from(softwareHashes)
.innerJoin(downloads, eq(downloads.id, softwareHashes.downloadId))
.innerJoin(entries, eq(entries.id, downloads.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
.leftJoin(
releases,
and(eq(releases.entryId, downloads.entryId), eq(releases.releaseSeq, downloads.releaseSeq))
)
.where(eq(softwareHashes.md5, md5.toLowerCase()));
// Collect unique entry IDs to fetch authors
const entryIds = [...new Set(rows.map((r) => Number(r.entryId)))];
const authorMap = new Map<number, string[]>();
if (entryIds.length > 0) {
try {
const authorRows = await db
.select({ entryId: authors.entryId, name: labels.name })
.from(authors)
.innerJoin(labels, eq(labels.id, authors.labelId))
.where(
entryIds.length === 1
? eq(authors.entryId, entryIds[0])
: sql`${authors.entryId} in (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)})`
)
.orderBy(asc(authors.authorSeq));
for (const a of authorRows) {
const eid = Number(a.entryId);
const existing = authorMap.get(eid);
if (existing) existing.push(a.name);
else authorMap.set(eid, [a.name]);
}
} catch {}
}
return rows.map((r) => ({
downloadId: Number(r.downloadId),
entryId: Number(r.entryId),
entryTitle: r.entryTitle ?? "",
innerPath: r.innerPath,
md5: r.md5,
crc32: r.crc32,
sizeBytes: Number(r.sizeBytes),
machinetype: r.machinetypeName ?? null,
genre: r.genreName ?? null,
releaseYear: r.releaseYear != null ? Number(r.releaseYear) : null,
authors: authorMap.get(Number(r.entryId)) ?? [],
downloadLink: r.downloadLink,
}));
}

View File

@@ -1,4 +1,4 @@
import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext } from "drizzle-orm/mysql-core"; import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext, bigint, timestamp } from "drizzle-orm/mysql-core";
// Minimal subset needed for browsing/searching // Minimal subset needed for browsing/searching
export const entries = mysqlTable("entries", { export const entries = mysqlTable("entries", {
@@ -646,3 +646,16 @@ export const zxsrScores = mysqlTable("zxsr_scores", {
score: varchar("score", { length: 100 }), score: varchar("score", { length: 100 }),
comments: text("comments"), comments: text("comments"),
}); });
// ---- Derived tables (managed by update scripts, not part of ZXDB upstream) ----
// Stores MD5, CRC32 and size of the inner tape file extracted from download zips.
// Populated by bin/update-software-hashes.mjs; survives DB wipes via JSON snapshot.
export const softwareHashes = mysqlTable("software_hashes", {
downloadId: int("download_id").notNull().primaryKey(),
md5: varchar("md5", { length: 32 }).notNull(),
crc32: varchar("crc32", { length: 8 }).notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
innerPath: varchar("inner_path", { length: 500 }).notNull(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

View File

@@ -1,20 +1,15 @@
import { cache } from 'react';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { Register } from '@/utils/register_parser'; import { Register, parseNextReg } from '@/utils/register_parser';
import { parseNextReg } from '@/utils/register_parser';
let registers: Register[] = [];
/** /**
* Gets the registers from the in-memory cache, or loads them from the file if not already loaded. * Gets all registers, with request-level deduplication via React cache().
* @returns A promise that resolves to an array of Register objects. * Multiple calls within the same server request (e.g. generateMetadata + page)
* share a single parse result.
*/ */
export async function getRegisters(): Promise<Register[]> { export const getRegisters = cache(async (): Promise<Register[]> => {
// if (registers.length === 0) { const filePath = path.join(process.cwd(), 'data', 'nextreg.txt');
const filePath = path.join(process.cwd(), 'data', 'nextreg.txt'); const fileContent = await fs.readFile(filePath, 'utf8');
const fileContent = await fs.readFile(filePath, 'utf8'); return parseNextReg(fileContent);
registers = await parseNextReg(fileContent); });
// }
return registers;
}

25
src/types/zxdb.ts Normal file
View File

@@ -0,0 +1,25 @@
/** Paginated API response wrapper */
export type PagedResult<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
/** Facet item returned by search APIs */
export type FacetItem<T = number> = {
id: T;
name: string;
count: number;
};
/** Entry search facets */
export type EntryFacets = {
genres: FacetItem<number>[];
languages: FacetItem<string>[];
machinetypes: FacetItem<number>[];
flags: { hasAliases: number; hasOrigins: number };
};
/** Entry search scope */
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";

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

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

29
src/utils/params.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Parse a comma-separated string of positive integer IDs.
* Accepts either a plain string or a string[] (from searchParams).
*/
export function parseIdList(value: string | string[] | undefined): number[] | 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;
}
/** Default machine type IDs for entry/release searches */
export const preferredMachineIds = [27, 26, 8, 9];
/**
* Parse machine type IDs from a comma-separated string,
* falling back to preferredMachineIds when empty.
*/
export function parseMachineIds(value?: string): number[] {
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();
}

View File

@@ -0,0 +1,93 @@
type RegisterLike = {
description: string;
text: string;
modes: { text: string }[];
};
/** Returns true if a line contains useful register info (not bitfield/access markers) */
export function isInfoLine(line: string): boolean {
return (
line.length > 0 &&
!line.startsWith("//") &&
!line.startsWith("(R") &&
!line.startsWith("(W") &&
!line.startsWith("(R/W") &&
!line.startsWith("*") &&
!/^bits?\s+\d/i.test(line)
);
}
/** Build a single-line summary string for metadata descriptions */
export function buildRegisterSummary(register: RegisterLike): string {
const trimLine = (line: string) => line.trim();
const modeLines = register.modes
.flatMap((mode) => mode.text.split("\n"))
.map(trimLine)
.filter(isInfoLine);
const textLines = register.text.split("\n").map(trimLine).filter(isInfoLine);
const descriptionLines = register.description.split("\n").map(trimLine).filter(isInfoLine);
const rawSummary = [...textLines, ...modeLines, ...descriptionLines]
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (!rawSummary) return "Spectrum Next register details and bit-level behavior.";
if (rawSummary.length <= 180) return rawSummary;
return `${rawSummary.slice(0, 177).trimEnd()}...`;
}
/** Build deduplicated summary lines for OG image rendering */
export function buildRegisterSummaryLines(register: RegisterLike): string[] {
const normalizeLines = (raw: string) => {
const lines: string[] = [];
const rawLines = raw.split("\n");
for (const rawLine of rawLines) {
const trimmed = rawLine.trim();
if (!trimmed) {
if (lines.length > 0 && lines[lines.length - 1] !== "") {
lines.push("");
}
continue;
}
if (isInfoLine(trimmed)) {
lines.push(trimmed);
}
}
return lines;
};
const textLines = normalizeLines(register.text);
const modeLines = register.modes.flatMap((mode) => normalizeLines(mode.text));
const descriptionLines = normalizeLines(register.description);
const combined: string[] = [];
const appendBlock = (block: string[]) => {
if (block.length === 0) return;
if (combined.length > 0 && combined[combined.length - 1] !== "") {
combined.push("");
}
combined.push(...block);
};
appendBlock(textLines);
appendBlock(modeLines);
appendBlock(descriptionLines);
const deduped: string[] = [];
const seen = new Set<string>();
for (const line of combined) {
if (!line) {
if (deduped.length > 0 && deduped[deduped.length - 1] !== "") {
deduped.push("");
}
continue;
}
if (seen.has(line)) continue;
seen.add(line);
deduped.push(line);
}
return deduped.length > 0 ? deduped : ["Spectrum Next register details and bit-level behavior."];
}

Some files were not shown because too many files have changed in this diff Show More