Merge branch 'dev'
This commit is contained in:
37
AGENTS.md
37
AGENTS.md
@@ -67,7 +67,7 @@ next-explorer/
|
|||||||
- `RegisterDetail.tsx`: Client Component that renders a single register’s details, including modes, notes, and source modal.
|
- `RegisterDetail.tsx`: Client Component that renders a single register’s details, including modes, notes, and source modal.
|
||||||
- `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address.
|
- `[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,7 +184,11 @@ 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
|
||||||
|
|
||||||
- ZXDB setup and API usage: `docs/ZXDB.md`
|
- ZXDB setup and API usage: `docs/ZXDB.md`
|
||||||
|
|||||||
2
ZXDB
2
ZXDB
Submodule ZXDB updated: 3784c91bdd...dc2edad9ec
@@ -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
498
bin/update-software-hashes.mjs
Executable file
@@ -0,0 +1,498 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Compute MD5, CRC32 and size for the inner tape file inside each download zip.
|
||||||
|
// Populates the `software_hashes` table and exports a JSON snapshot to
|
||||||
|
// data/zxdb/software_hashes.json for reimport after DB wipes.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node bin/update-software-hashes.mjs [flags]
|
||||||
|
//
|
||||||
|
// Flags:
|
||||||
|
// --rebuild-all Ignore state and reprocess every download
|
||||||
|
// --rebuild-missing Only process downloads not yet in software_hashes
|
||||||
|
// --start-from-id=N Start processing from download id N
|
||||||
|
// --export-only Skip processing, just export current table to JSON
|
||||||
|
// --quiet Reduce log output
|
||||||
|
// --verbose Force verbose output (default)
|
||||||
|
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import dotenvExpand from "dotenv-expand";
|
||||||
|
dotenvExpand.expand(dotenv.config());
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { createReadStream } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
|
import { Transform } from "stream";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
// ---- CLI flags ----
|
||||||
|
const ARGV = new Set(process.argv.slice(2));
|
||||||
|
const QUIET = ARGV.has("--quiet");
|
||||||
|
const VERBOSE = ARGV.has("--verbose") || !QUIET;
|
||||||
|
const REBUILD_ALL = ARGV.has("--rebuild-all");
|
||||||
|
const REBUILD_MISSING = ARGV.has("--rebuild-missing");
|
||||||
|
const EXPORT_ONLY = ARGV.has("--export-only");
|
||||||
|
|
||||||
|
// Parse --start-from-id=N
|
||||||
|
let CLI_START_FROM = 0;
|
||||||
|
for (const arg of process.argv.slice(2)) {
|
||||||
|
const m = arg.match(/^--start-from-id=(\d+)$/);
|
||||||
|
if (m) CLI_START_FROM = parseInt(m[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInfo(msg) { if (VERBOSE) console.log(msg); }
|
||||||
|
function logWarn(msg) { console.warn(msg); }
|
||||||
|
function logError(msg) { console.error(msg); }
|
||||||
|
|
||||||
|
// ---- Environment ----
|
||||||
|
const envSchema = z.object({
|
||||||
|
ZXDB_URL: z.string().url().refine((s) => s.startsWith("mysql://"), {
|
||||||
|
message: "ZXDB_URL must be a valid mysql:// URL",
|
||||||
|
}),
|
||||||
|
CDN_CACHE: z.string().min(1, "CDN_CACHE must be set to the local CDN mirror root"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
if (!parsedEnv.success) {
|
||||||
|
logError("Invalid environment variables:\n" + JSON.stringify(parsedEnv.error.format(), null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const { ZXDB_URL, CDN_CACHE } = parsedEnv.data;
|
||||||
|
|
||||||
|
const SNAPSHOT_PATH = path.join(PROJECT_ROOT, "data", "zxdb", "software_hashes.json");
|
||||||
|
const STATE_FILE = path.join(CDN_CACHE, ".update-software-hashes.state.json");
|
||||||
|
|
||||||
|
// Filetype IDs for tape images
|
||||||
|
const TAPE_FILETYPE_IDS = [8, 22];
|
||||||
|
|
||||||
|
// Tape file extensions in priority order (most common first)
|
||||||
|
const TAPE_EXTENSIONS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
|
||||||
|
|
||||||
|
// ---- DB ----
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
uri: ZXDB_URL,
|
||||||
|
connectionLimit: 10,
|
||||||
|
maxPreparedStatements: 256,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Path mapping (mirrors sync-downloads.mjs) ----
|
||||||
|
function toLocalPath(fileLink) {
|
||||||
|
if (fileLink.startsWith("/zxdb/sinclair/")) {
|
||||||
|
return path.join(CDN_CACHE, "SC", fileLink.slice("/zxdb/sinclair".length));
|
||||||
|
}
|
||||||
|
if (fileLink.startsWith("/pub/sinclair/")) {
|
||||||
|
return path.join(CDN_CACHE, "WoS", fileLink.slice("/pub/sinclair".length));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- State management ----
|
||||||
|
async function loadState() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(STATE_FILE, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStateAtomic(state) {
|
||||||
|
const tmp = STATE_FILE + ".tmp";
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
|
||||||
|
await fs.rename(tmp, STATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Zip extraction ----
|
||||||
|
|
||||||
|
// Use Node.js built-in (node:zlib for deflate) + manual zip parsing
|
||||||
|
// to avoid external dependencies. Zip files in ZXDB are simple (no encryption, single file).
|
||||||
|
|
||||||
|
async function extractZipContents(zipPath, contentsDir) {
|
||||||
|
const { execFile } = await import("child_process");
|
||||||
|
const { promisify } = await import("util");
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
await fs.mkdir(contentsDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use system unzip, quoting the path to handle brackets in filenames
|
||||||
|
await execFileAsync("unzip", ["-o", "-d", contentsDir, zipPath], {
|
||||||
|
maxBuffer: 50 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// unzip returns exit code 1 for warnings (e.g. "appears to use backslashes")
|
||||||
|
// which is non-fatal — only fail on actual extraction errors
|
||||||
|
if (err.code !== 1) {
|
||||||
|
throw new Error(`unzip failed for ${zipPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Find tape file inside _CONTENTS ----
|
||||||
|
|
||||||
|
async function findTapeFile(contentsDir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(contentsDir, { recursive: true, withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all tape files grouped by extension priority
|
||||||
|
const candidates = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
const priority = TAPE_EXTENSIONS.indexOf(ext);
|
||||||
|
if (priority === -1) continue;
|
||||||
|
|
||||||
|
const fullPath = path.join(entry.parentPath ?? entry.path, entry.name);
|
||||||
|
candidates.push({ path: fullPath, ext, priority, name: entry.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
// Sort by priority (lowest index = highest priority), then alphabetically
|
||||||
|
candidates.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
// Return the best candidate
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hash computation ----
|
||||||
|
|
||||||
|
async function computeHashes(filePath) {
|
||||||
|
const md5 = createHash("md5");
|
||||||
|
let crc = 0xFFFFFFFF;
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
// CRC32 lookup table
|
||||||
|
const crcTable = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
crcTable[i] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = new Transform({
|
||||||
|
transform(chunk, encoding, callback) {
|
||||||
|
md5.update(chunk);
|
||||||
|
size += chunk.length;
|
||||||
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
|
crc = crcTable[(crc ^ chunk[i]) & 0xFF] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
// Pipe through transform (which computes hashes) and discard output
|
||||||
|
await pipeline(stream, transform, async function* (source) {
|
||||||
|
for await (const _ of source) { /* drain */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
const crc32Final = ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0");
|
||||||
|
return {
|
||||||
|
md5: md5.digest("hex"),
|
||||||
|
crc32: crc32Final,
|
||||||
|
sizeBytes: size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ensure software_hashes table exists ----
|
||||||
|
|
||||||
|
async function ensureTable() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS software_hashes (
|
||||||
|
download_id INT NOT NULL PRIMARY KEY,
|
||||||
|
md5 VARCHAR(32) NOT NULL,
|
||||||
|
crc32 VARCHAR(8) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
inner_path VARCHAR(500) NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sh_md5 (md5),
|
||||||
|
INDEX idx_sh_crc32 (crc32)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON export ----
|
||||||
|
|
||||||
|
async function exportSnapshot() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT download_id, md5, crc32, size_bytes, inner_path, updated_at FROM software_hashes ORDER BY download_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
count: rows.length,
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
download_id: r.download_id,
|
||||||
|
md5: r.md5,
|
||||||
|
crc32: r.crc32,
|
||||||
|
size_bytes: Number(r.size_bytes),
|
||||||
|
inner_path: r.inner_path,
|
||||||
|
updated_at: r.updated_at instanceof Date ? r.updated_at.toISOString() : r.updated_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(path.dirname(SNAPSHOT_PATH), { recursive: true });
|
||||||
|
|
||||||
|
// Atomic write
|
||||||
|
const tmp = SNAPSHOT_PATH + ".tmp";
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(snapshot, null, 2), "utf8");
|
||||||
|
await fs.rename(tmp, SNAPSHOT_PATH);
|
||||||
|
|
||||||
|
logInfo(`Exported ${rows.length} rows to ${SNAPSHOT_PATH}`);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Main processing loop ----
|
||||||
|
|
||||||
|
let currentState = null;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureTable();
|
||||||
|
|
||||||
|
if (EXPORT_ONLY) {
|
||||||
|
const count = await exportSnapshot();
|
||||||
|
logInfo(`Export complete: ${count} rows.`);
|
||||||
|
await pool.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine start point
|
||||||
|
const prior = await loadState();
|
||||||
|
let resumeFrom = CLI_START_FROM;
|
||||||
|
if (!REBUILD_ALL && !CLI_START_FROM && prior?.lastProcessedId) {
|
||||||
|
resumeFrom = prior.lastProcessedId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
currentState = {
|
||||||
|
version: 1,
|
||||||
|
startedAt,
|
||||||
|
updatedAt: startedAt,
|
||||||
|
startFromId: resumeFrom,
|
||||||
|
lastProcessedId: prior?.lastProcessedId ?? -1,
|
||||||
|
processed: 0,
|
||||||
|
hashed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query tape-image downloads
|
||||||
|
const placeholders = TAPE_FILETYPE_IDS.map(() => "?").join(", ");
|
||||||
|
|
||||||
|
let rows;
|
||||||
|
if (REBUILD_MISSING) {
|
||||||
|
// Only fetch downloads that don't already have a hash
|
||||||
|
[rows] = await pool.query(
|
||||||
|
`SELECT d.id, d.file_link, d.file_size FROM downloads d
|
||||||
|
LEFT JOIN software_hashes sh ON sh.download_id = d.id
|
||||||
|
WHERE d.filetype_id IN (${placeholders}) AND sh.download_id IS NULL
|
||||||
|
ORDER BY d.id ASC`,
|
||||||
|
TAPE_FILETYPE_IDS
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
[rows] = await pool.query(
|
||||||
|
`SELECT id, file_link, file_size FROM downloads
|
||||||
|
WHERE filetype_id IN (${placeholders}) AND id >= ?
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[...TAPE_FILETYPE_IDS, resumeFrom]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get total count for progress display
|
||||||
|
const [totalRows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM downloads WHERE filetype_id IN (${placeholders})`,
|
||||||
|
TAPE_FILETYPE_IDS
|
||||||
|
);
|
||||||
|
const total = totalRows[0].cnt;
|
||||||
|
|
||||||
|
const mode = REBUILD_MISSING ? "missing only" : REBUILD_ALL ? "rebuild all" : `from id >= ${resumeFrom}`;
|
||||||
|
logInfo(`Processing ${rows.length} tape-image downloads (total in DB: ${total}, mode: ${mode})`);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let hashed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const { id, file_link: fileLink } = row;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localZip = toLocalPath(fileLink);
|
||||||
|
if (!localZip) {
|
||||||
|
// /denied/ and other non-hosted prefixes — skip silently
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
if (processed % 500 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if zip exists locally
|
||||||
|
try {
|
||||||
|
await fs.access(localZip);
|
||||||
|
} catch {
|
||||||
|
// Zip not synced yet — skip silently
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
if (processed % 500 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check/create _CONTENTS
|
||||||
|
const contentsDir = localZip + "_CONTENTS";
|
||||||
|
let contentsExisted = false;
|
||||||
|
try {
|
||||||
|
await fs.access(contentsDir);
|
||||||
|
contentsExisted = true;
|
||||||
|
} catch {
|
||||||
|
// Need to extract
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentsExisted) {
|
||||||
|
try {
|
||||||
|
await extractZipContents(localZip, contentsDir);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn(` [${id}] Extract failed: ${err.message}`);
|
||||||
|
errors++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tape file
|
||||||
|
const tapeFile = await findTapeFile(contentsDir);
|
||||||
|
if (!tapeFile) {
|
||||||
|
// No tape file found inside zip — unusual but not fatal
|
||||||
|
if (VERBOSE) logWarn(` [${id}] No tape file in ${contentsDir}`);
|
||||||
|
skipped++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hashes
|
||||||
|
const hashes = await computeHashes(tapeFile.path);
|
||||||
|
|
||||||
|
// Relative path inside _CONTENTS for the inner_path column
|
||||||
|
const innerPath = path.relative(contentsDir, tapeFile.path);
|
||||||
|
|
||||||
|
// Upsert
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO software_hashes (download_id, md5, crc32, size_bytes, inner_path, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
md5 = VALUES(md5),
|
||||||
|
crc32 = VALUES(crc32),
|
||||||
|
size_bytes = VALUES(size_bytes),
|
||||||
|
inner_path = VALUES(inner_path),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[id, hashes.md5, hashes.crc32, hashes.sizeBytes, innerPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
hashed++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (processed % 100 === 0) {
|
||||||
|
await checkpoint();
|
||||||
|
logInfo(`... processed=${processed}/${rows.length}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError(` [${id}] Unexpected error: ${err.message}`);
|
||||||
|
errors++;
|
||||||
|
processed++;
|
||||||
|
currentState.lastProcessedId = id;
|
||||||
|
currentState.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final state save
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
|
||||||
|
logInfo(`\nProcessing complete: processed=${processed}, hashed=${hashed}, skipped=${skipped}, errors=${errors}`);
|
||||||
|
|
||||||
|
// Export snapshot
|
||||||
|
logInfo("\nExporting JSON snapshot...");
|
||||||
|
await exportSnapshot();
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
logInfo("Done.");
|
||||||
|
|
||||||
|
async function checkpoint() {
|
||||||
|
currentState.processed = processed;
|
||||||
|
currentState.hashed = hashed;
|
||||||
|
currentState.skipped = skipped;
|
||||||
|
currentState.errors = errors;
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Graceful shutdown ----
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
logWarn("\nInterrupted (SIGINT). Writing state...");
|
||||||
|
try {
|
||||||
|
if (currentState) {
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
logWarn(`State saved at: ${STATE_FILE}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state on SIGINT: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
try { await pool.end(); } catch {}
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run
|
||||||
|
main().catch(async (err) => {
|
||||||
|
logError(`Fatal error: ${err.message}\n${err.stack || "<no stack>"}`);
|
||||||
|
try {
|
||||||
|
if (currentState) {
|
||||||
|
currentState.updatedAt = new Date().toISOString();
|
||||||
|
currentState.error = { message: err.message, stack: err.stack };
|
||||||
|
await saveStateAtomic(currentState);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to write state on fatal: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
try { await pool.end(); } catch {}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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
0
data/zxdb/.gitkeep
Normal file
263686
data/zxdb/software_hashes.json
Normal file
263686
data/zxdb/software_hashes.json
Normal file
File diff suppressed because it is too large
Load Diff
28
docs/ZXDB.md
28
docs/ZXDB.md
@@ -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
89
docs/changelog.md
Normal 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
|
||||||
75
docs/plans/plan_feature-software-hashes_implimentation.md
Normal file
75
docs/plans/plan_feature-software-hashes_implimentation.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# WIP: Software Hashes
|
||||||
|
|
||||||
|
**Branch:** `feature/software-hashes`
|
||||||
|
**Started:** 2026-02-17
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Implements [docs/plans/software-hashes.md](software-hashes.md) — a derived `software_hashes` table storing MD5, CRC32 and size for tape-image contents extracted from download zips.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- [x] Create `data/zxdb/` directory (for JSON snapshot)
|
||||||
|
- [x] Add `software_hashes` Drizzle schema model
|
||||||
|
- [x] Create `bin/update-software-hashes.mjs` — main pipeline script
|
||||||
|
- [x] DB query for tape-image downloads (filetype_id IN 8, 22)
|
||||||
|
- [x] Resolve local zip path via CDN mapping (uses CDN_CACHE env var)
|
||||||
|
- [x] Extract `_CONTENTS` (skip if exists)
|
||||||
|
- [x] Find tape file (.tap/.tzx/.pzx/.csw) with priority order
|
||||||
|
- [x] Compute MD5, CRC32, size_bytes
|
||||||
|
- [x] Upsert into software_hashes
|
||||||
|
- [x] State file for resume support
|
||||||
|
- [x] JSON export after bulk update (atomic write)
|
||||||
|
- [x] Update `bin/import_mysql.sh` to reimport snapshot on DB wipe
|
||||||
|
- [x] Add pnpm script entries
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### 2026-02-17T16:00Z
|
||||||
|
- Started work. Branch created from `main` at `b361201`.
|
||||||
|
- Explored codebase: understood DB schema, CDN mapping, import pipeline.
|
||||||
|
- Key findings:
|
||||||
|
- filetype_id 8 = "Tape image" (33,427 rows), 22 = "BUGFIX tape image" (98 rows)
|
||||||
|
- CDN_CACHE = /Volumes/McFiver/CDN, paths: SC/ (zxdb) and WoS/ (pub)
|
||||||
|
- `_CONTENTS` dirs exist in WoS but not yet in SC
|
||||||
|
- data/zxdb/ directory needs creation
|
||||||
|
- import_mysql.sh needs software_hashes reimport step
|
||||||
|
|
||||||
|
### 2026-02-17T16:04Z
|
||||||
|
- Implemented Drizzle schema model for `software_hashes`.
|
||||||
|
- Created `bin/update-software-hashes.mjs` pipeline script.
|
||||||
|
- Updated `bin/import_mysql.sh` with JSON snapshot reimport.
|
||||||
|
- Added `update:hashes` and `export:hashes` pnpm scripts.
|
||||||
|
|
||||||
|
### 2026-02-17T16:09Z
|
||||||
|
- First full run completed successfully:
|
||||||
|
- 33,525 total tape-image downloads in DB
|
||||||
|
- 32,305 rows hashed and inserted into software_hashes
|
||||||
|
- ~1,220 skipped (missing local zips, `/denied/` prefix, `.p` ZX81 files with no tape content)
|
||||||
|
- JSON snapshot exported: 7.2MB, 32,305 rows at `data/zxdb/software_hashes.json`
|
||||||
|
- All plan steps verified working.
|
||||||
|
|
||||||
|
## Decisions & Notes
|
||||||
|
|
||||||
|
- Target filetype IDs: 8 and 22 (tape image + bugfix tape image).
|
||||||
|
- Tape file priority: .tap > .tzx > .pzx > .csw (most common first).
|
||||||
|
- CDN_CACHE comes from env var (not hard-coded, unlike sync-downloads.mjs).
|
||||||
|
- JSON snapshot at data/zxdb/software_hashes.json (7.2MB, committed to repo).
|
||||||
|
- Node.js built-in `crypto` for MD5; custom CRC32 lookup table (no external deps).
|
||||||
|
- `inner_path` column added (not in original plan) to record which file inside the zip was hashed.
|
||||||
|
- `/denied/` and `/nvg/` prefix downloads (~443) are logged and skipped (no local mirror).
|
||||||
|
- `.p` files (ZX81 programs) categorized as tape images but contain no .tap/.tzx/.pzx/.csw — logged as "no tape file".
|
||||||
|
- Uses system `unzip` for extraction (handles bracket-heavy filenames via `execFile` not shell).
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
b361201 - Ready to start adding hashes
|
||||||
|
944a2dc - wip: start feature/software-hashes — init progress tracker
|
||||||
|
f5ae89e - feat: add software_hashes table schema and reimport pipeline
|
||||||
|
edc937a - feat: add update-software-hashes.mjs pipeline script
|
||||||
|
9bfebc1 - feat: add initial software_hashes JSON snapshot (32,305 rows)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# WIP: Tape Identifier Dropzone
|
||||||
|
|
||||||
|
**Branch:** `feature/software-hashes`
|
||||||
|
**Started:** 2026-02-17
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Implements the tape identifier feature from [docs/plans/tape-identifier.md](tape-identifier.md).
|
||||||
|
|
||||||
|
Drop a tape file on the /zxdb page → client computes MD5 + size → server action looks up `software_hashes` → returns identified ZXDB entry.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- [x] Add `lookupByMd5()` to `src/server/repo/zxdb.ts`
|
||||||
|
- [x] Create `src/utils/md5.ts` — pure-JS MD5 for browser
|
||||||
|
- [x] Create `src/app/zxdb/actions.ts` — server action `identifyTape`
|
||||||
|
- [x] Create `src/app/zxdb/TapeIdentifier.tsx` — client component with dropzone
|
||||||
|
- [x] Insert `<TapeIdentifier />` into `src/app/zxdb/page.tsx`
|
||||||
|
- [ ] Verify on http://localhost:4000/zxdb
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### 2026-02-17T00:00
|
||||||
|
- Started work. Continuing on `feature/software-hashes` at `e27a16e`.
|
||||||
|
|
||||||
|
### 2026-02-17T00:01
|
||||||
|
- All implementation complete. Type check passes. Ready for visual verification.
|
||||||
|
|
||||||
|
## Decisions & Notes
|
||||||
|
|
||||||
|
- Uses RSC server actions (not API routes) to discourage bulk scripting.
|
||||||
|
- MD5 computed client-side; file never leaves the browser.
|
||||||
|
- No new npm dependencies — pure-JS MD5 implementation (~130 lines).
|
||||||
|
- TapeIdentifier placed between hero and "Start exploring" grid in a row layout with explanatory text alongside.
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
fc513c5 - wip: start tape identifier — init progress tracker
|
||||||
|
8624050 - feat: add tape identifier dropzone on /zxdb
|
||||||
155
docs/plans/software-hashes.md
Normal file
155
docs/plans/software-hashes.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Software Hashes Plan
|
||||||
|
|
||||||
|
Plan for adding a derived `software_hashes` table, its update pipeline, and JSON snapshot lifecycle to survive DB wipes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Goals and Scope (Plan Step 1)
|
||||||
|
|
||||||
|
- Create and maintain `software_hashes` for (at this stage) tape-image downloads.
|
||||||
|
- Preserve existing `_CONTENTS` folders; only create missing ones.
|
||||||
|
- Export `software_hashes` to JSON after each bulk update.
|
||||||
|
- Reimport `software_hashes` JSON during DB wipe in `bin/import_mysql.sh` (or a helper script it invokes).
|
||||||
|
- Ensure all scripts are idempotent and resume-safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Confirm Pipeline Touchpoints (Plan Step 2)
|
||||||
|
|
||||||
|
- Verify `bin/import_mysql.sh` is the authoritative DB wipe/import entry point.
|
||||||
|
- Confirm `bin/sync-downloads.mjs` remains responsible only for CDN cache sync.
|
||||||
|
- Confirm `src/server/schema/zxdb.ts` uses `downloads.id` as the natural FK target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Define Data Model: `software_hashes` (Plan Step 3)
|
||||||
|
|
||||||
|
### Table naming and FK alignment
|
||||||
|
|
||||||
|
- Table: `software_hashes`.
|
||||||
|
- FK: `download_id` → `downloads.id`.
|
||||||
|
- Column names follow existing DB `snake_case` conventions.
|
||||||
|
|
||||||
|
### Planned columns
|
||||||
|
|
||||||
|
- `download_id` (PK or unique index; FK to `downloads.id`)
|
||||||
|
- `md5`
|
||||||
|
- `crc32`
|
||||||
|
- `size_bytes`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
### Planned indexes / constraints
|
||||||
|
|
||||||
|
- Unique index on `download_id`.
|
||||||
|
- Index on `md5` for reverse lookup.
|
||||||
|
- Index on `crc32` for reverse lookup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Define JSON Snapshot Format (Plan Step 4)
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
- Default: `data/zxdb/software_hashes.json` (or another agreed path).
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exportedAt": "2026-02-17T15:18:00.000Z",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"download_id": 123,
|
||||||
|
"md5": "...",
|
||||||
|
"crc32": "...",
|
||||||
|
"size_bytes": 12345,
|
||||||
|
"updated_at": "2026-02-17T15:18:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Planned import policy
|
||||||
|
|
||||||
|
- If snapshot exists: truncate `software_hashes` and bulk insert.
|
||||||
|
- If snapshot missing: log and continue without error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Implement Tape Image Update Workflow (Plan Step 5)
|
||||||
|
|
||||||
|
### Planned script
|
||||||
|
|
||||||
|
- `bin/update-software-hashes.mjs` (name can be adjusted).
|
||||||
|
|
||||||
|
### Planned input dataset
|
||||||
|
|
||||||
|
- Query `downloads` for tape-image rows (filter by `filetype_id` or joined `filetypes` table).
|
||||||
|
|
||||||
|
### Planned per-item process
|
||||||
|
|
||||||
|
1. Resolve local zip path using the same CDN mapping used by `sync-downloads`.
|
||||||
|
2. Compute `_CONTENTS` folder name: `<zip filename>_CONTENTS` (exact match).
|
||||||
|
3. If `_CONTENTS` exists, keep it untouched.
|
||||||
|
4. If missing, extract zip into `_CONTENTS` using a library that avoids shell expansion issues with brackets.
|
||||||
|
5. Locate tape file inside (`.tap`, `.tzx`, `.pzx`, `.csw`):
|
||||||
|
- Apply a deterministic priority order.
|
||||||
|
- If multiple candidates remain, log and skip (or record ambiguity).
|
||||||
|
6. Compute `md5`, `crc32`, and `size_bytes` for the selected file.
|
||||||
|
7. Upsert into `software_hashes` keyed by `download_id`.
|
||||||
|
|
||||||
|
### Planned error handling
|
||||||
|
|
||||||
|
- Log missing zips or missing tape files.
|
||||||
|
- Continue after recoverable errors; fail only on critical DB errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Implement JSON Export Lifecycle (Plan Step 6)
|
||||||
|
|
||||||
|
- After each bulk update, export `software_hashes` to JSON.
|
||||||
|
- Write atomically (temp file + rename).
|
||||||
|
- Include `exportedAt` timestamp in snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Reimport During Wipe (`bin/import_mysql.sh`) (Plan Step 7)
|
||||||
|
|
||||||
|
### Planned placement
|
||||||
|
|
||||||
|
- Immediately after database creation and ZXDB SQL import completes.
|
||||||
|
|
||||||
|
### Planned behavior
|
||||||
|
|
||||||
|
- Attempt to read JSON snapshot.
|
||||||
|
- If present, truncate and reinsert `software_hashes`.
|
||||||
|
- Log imported row count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Add Idempotency and Resume Support (Plan Step 8)
|
||||||
|
|
||||||
|
- State file similar to `.sync-downloads.state.json` to track last `download_id` processed.
|
||||||
|
- CLI flags:
|
||||||
|
- `--resume` (default)
|
||||||
|
- `--start-from-id`
|
||||||
|
- `--rebuild-all`
|
||||||
|
- Reprocess when zip file size or mtime changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Validation Checklist (Plan Step 9)
|
||||||
|
|
||||||
|
- `_CONTENTS` folders are never deleted.
|
||||||
|
- Hashes match expected MD5/CRC32 for known samples.
|
||||||
|
- JSON snapshot is created and reimported correctly.
|
||||||
|
- Reverse lookup by `md5`/`crc32`/`size_bytes` identifies misnamed files.
|
||||||
|
- Script can resume safely after interruption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Open Questions / Confirmations (Plan Step 10)
|
||||||
|
|
||||||
|
- Final `software_hashes` column list and types.
|
||||||
|
- Exact JSON snapshot path.
|
||||||
|
- Filetype IDs that map to “Tape Image” in `downloads`.
|
||||||
67
docs/plans/tape-identifier.md
Normal file
67
docs/plans/tape-identifier.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Plan: Tape Identifier Dropzone on /zxdb
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We have 32,960 rows in `software_hashes` with MD5, CRC32, size, and inner_path for tape-image contents. This feature exposes that data to users: drop a tape file, get it identified against the ZXDB database.
|
||||||
|
|
||||||
|
Uses RSC (server actions) rather than an API endpoint to make bulk scripted identification harder.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Client-side:** Compute MD5 + file size in the browser, then call a server action with just those two values (file never leaves the client).
|
||||||
|
|
||||||
|
**Server-side:** A Next.js Server Action looks up `software_hashes` by MD5 (and optionally size_bytes for disambiguation), joins to `downloads` and `entries` to return the entry title, download details, and a link.
|
||||||
|
|
||||||
|
**Client-side MD5:** Web Crypto doesn't support MD5. Include a small pure-JS MD5 utility (~80 lines, well-known algorithm). No new npm dependencies.
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### 1. `src/utils/md5.ts` — Pure-JS MD5 for browser use
|
||||||
|
- Exports `async function computeMd5(file: File): Promise<string>`
|
||||||
|
- Reads file as ArrayBuffer, computes MD5, returns hex string
|
||||||
|
- Standard MD5 algorithm implementation, typed for TypeScript
|
||||||
|
|
||||||
|
### 2. `src/app/zxdb/actions.ts` — Server Action
|
||||||
|
- `'use server'` directive
|
||||||
|
- `identifyTape(md5: string, sizeBytes: number)`
|
||||||
|
- Queries `software_hashes` JOIN `downloads` JOIN `entries` by MD5
|
||||||
|
- If multiple matches and size_bytes narrows it, filter further
|
||||||
|
- Returns array of `{ downloadId, entryId, entryTitle, innerPath, md5, crc32, sizeBytes }`
|
||||||
|
|
||||||
|
### 3. `src/app/zxdb/TapeIdentifier.tsx` — Client Component
|
||||||
|
- `'use client'`
|
||||||
|
- States: `idle` → `hashing` → `identifying` → `results` / `not-found`
|
||||||
|
- Dropzone UI:
|
||||||
|
- Dashed border card, large tape icon, "Drop a tape file to identify it"
|
||||||
|
- Lists supported formats: `.tap .tzx .pzx .csw .p .o`
|
||||||
|
- Also has a hidden `<input type="file">` with a "or choose file" link
|
||||||
|
- Drag-over highlight state
|
||||||
|
- On file drop/select:
|
||||||
|
- Validate extension against supported list
|
||||||
|
- Show spinner + "Computing hash..."
|
||||||
|
- Compute MD5 + size client-side
|
||||||
|
- Call server action `identifyTape(md5, size)`
|
||||||
|
- Show spinner + "Searching ZXDB..."
|
||||||
|
- Results view (replaces dropzone):
|
||||||
|
- Match found: entry title as link to `/zxdb/entries/{id}`, inner filename, MD5, file size
|
||||||
|
- Multiple matches: list all
|
||||||
|
- No match: "No matching tape found in ZXDB"
|
||||||
|
- "Identify another tape" button to reset
|
||||||
|
|
||||||
|
### 4. `src/app/zxdb/page.tsx` — Add TapeIdentifier section
|
||||||
|
- Insert `<TapeIdentifier />` as a new section between the hero and "Start exploring" grid
|
||||||
|
- Wrap in a card with distinct styling to make it visually prominent
|
||||||
|
|
||||||
|
### 5. `src/server/repo/zxdb.ts` — Add lookup function
|
||||||
|
- `lookupByMd5(md5: string)` — joins `software_hashes` → `downloads` → `entries`
|
||||||
|
- Returns download_id, entry_id, entry title, inner_path, hash details
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Visit http://localhost:4000/zxdb
|
||||||
|
- Dropzone should be visible and prominent between hero and navigation grid
|
||||||
|
- Drop a known .tap/.tzx file → should show the identified entry with a link
|
||||||
|
- Drop an unknown file → should show "No matching tape found"
|
||||||
|
- Click "Identify another tape" → resets to dropzone
|
||||||
|
- Check file never leaves browser (Network tab: only the server action call with md5 + size)
|
||||||
|
- Verify non-supported extensions are rejected with helpful message
|
||||||
137
docs/plans/zxdb-missing-features.md
Normal file
137
docs/plans/zxdb-missing-features.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# ZXDB Explorer — Missing Features & Gaps
|
||||||
|
|
||||||
|
Audit of the `/zxdb` pages against the ZXDB schema and existing data. Everything listed below is backed by tables already present in the Drizzle schema (`src/server/schema/zxdb.ts`) but not yet surfaced in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Coverage
|
||||||
|
|
||||||
|
| Section | List page | Detail page | Facets/Filters |
|
||||||
|
|----------------|-----------|-------------|---------------------------------|
|
||||||
|
| Entries | Search | Full detail | genre, language, machinetype |
|
||||||
|
| Releases | Search | Downloads, scraps, files, magazine refs | — |
|
||||||
|
| Labels | Search | Authored/published entries, permissions, licenses | — |
|
||||||
|
| Magazines | Search | Issues list | — |
|
||||||
|
| Issues | via magazine | Magazine refs (reviews/references) | — |
|
||||||
|
| Genres | List | Entries by genre | — |
|
||||||
|
| Languages | List | Entries by language | — |
|
||||||
|
| Machine Types | List | Entries by type | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Top-Level Browse Pages
|
||||||
|
|
||||||
|
### 1. Countries
|
||||||
|
- **Tables:** `countries`, `labels.country_id`
|
||||||
|
- **Value:** Browse by country ("all software from Spain", "UK publishers").
|
||||||
|
|
||||||
|
### 2. Tools
|
||||||
|
- **Tables:** `tools`, `tooltypes`
|
||||||
|
- **Value:** Utilities, emulators, and development tools catalogued in ZXDB.
|
||||||
|
|
||||||
|
### 3. Features
|
||||||
|
- **Tables:** `features`
|
||||||
|
- **Value:** Hardware/software features (Multiface, Kempston joystick, etc.).
|
||||||
|
|
||||||
|
### 4. Topics
|
||||||
|
- **Tables:** `topics`, `topictypes`
|
||||||
|
- **Value:** Editorial/thematic groupings used by magazines.
|
||||||
|
|
||||||
|
### 5. Tags / Collections
|
||||||
|
- **Tables:** `tags`, `tagtypes`, `members`
|
||||||
|
- **Value:** Tags are shown per-entry but there is no top-level "browse by tag" page (e.g. all CSSCGC entries, compilations).
|
||||||
|
|
||||||
|
### 6. Licenses
|
||||||
|
- **Tables:** `licenses`, `licensetypes`, `relatedlicenses`, `licensors`
|
||||||
|
- **Value:** Shown per-entry detail but no "browse all licenses" hub (e.g. all games based on a Marvel license).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Cross-Links & Facets on Existing Pages
|
||||||
|
|
||||||
|
### 7. Magazine reviews on Entry detail
|
||||||
|
- Release detail shows magazine refs, but entry detail does **not** aggregate them.
|
||||||
|
- A user viewing an entry cannot see "reviewed in Crash #42, p.34" without drilling into each release.
|
||||||
|
|
||||||
|
### 8. Year / date filter on Entries
|
||||||
|
- ZXDB has `release_year` on releases. No year facet on the entries explorer.
|
||||||
|
- Users cannot browse "all games from 1985".
|
||||||
|
|
||||||
|
### 9. Availability type filter on Entries
|
||||||
|
- `availabletypes` API route exists but is not a facet on the entries explorer.
|
||||||
|
- Would allow filtering by "Never released", "MIA", etc.
|
||||||
|
|
||||||
|
### 10. Max players filter on Entries
|
||||||
|
- `entries.max_players` exists but is not filterable.
|
||||||
|
- Would enable "all multiplayer games".
|
||||||
|
|
||||||
|
### 11. Label type filter on Labels page
|
||||||
|
- `labeltypes` table exists and `roletypes` API is served.
|
||||||
|
- Cannot filter the labels list by type (person / company / team / magazine).
|
||||||
|
|
||||||
|
### 12. Country filter on Labels page
|
||||||
|
- Labels have `country_id` but no filter on the list page.
|
||||||
|
|
||||||
|
### 13. Country / language filter on Magazines page
|
||||||
|
- Magazine list has search but no country or language filter chips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Data on Detail Pages
|
||||||
|
|
||||||
|
### 14. Entry detail: magazine reviews section
|
||||||
|
- `search_by_magrefs` is used in release detail but entry detail does not aggregate magazine references across all releases.
|
||||||
|
- Same issue as #7 — the entry page should show a combined reviews/references panel.
|
||||||
|
|
||||||
|
### 15. Label detail: country display
|
||||||
|
- Labels have `country_id` / `country2_id` but the detail page does not show them.
|
||||||
|
|
||||||
|
### 16. Label detail: Wikipedia / website links
|
||||||
|
- `labels.link_wikipedia` and `labels.link_site` exist but are not displayed on the label detail page.
|
||||||
|
|
||||||
|
### 17. Entry detail: related entries via same license
|
||||||
|
- Licenses are shown per-entry but there is no click-through to "other games with this license".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entirely Unsurfaced Datasets
|
||||||
|
|
||||||
|
### 18. NVGs
|
||||||
|
- **Table:** `nvgs`
|
||||||
|
- Historical download archive metadata. Not exposed anywhere.
|
||||||
|
|
||||||
|
### 19. SPEX entries / authors
|
||||||
|
- **Tables:** `spex_entries`, `spex_authors`
|
||||||
|
- No UI.
|
||||||
|
|
||||||
|
### 20. Awards
|
||||||
|
- **Table:** `zxsr_awards`, referenced by `magrefs.award_id`
|
||||||
|
- No awards browsing or display.
|
||||||
|
|
||||||
|
### 21. Review text
|
||||||
|
- **Table:** `zxsr_reviews` (`intro_text`, `review_text`)
|
||||||
|
- Magazine refs link to reviews by ID but the actual review text is never rendered.
|
||||||
|
|
||||||
|
### 22. Articles
|
||||||
|
- **Tables:** `articles`, `articletypes`
|
||||||
|
- No articles browsing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation / UX Gaps
|
||||||
|
|
||||||
|
### 23. No discovery mechanism
|
||||||
|
- No "random entry", "on this day", or "featured" section. Common for large historic databases.
|
||||||
|
|
||||||
|
### 24. No stats / dashboard
|
||||||
|
- No summary counts ("ZXDB has X entries, Y labels, Z magazines"). Would anchor the landing page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Priority
|
||||||
|
|
||||||
|
| Priority | Items | Rationale |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| High | 7/14 (magazine refs on entry detail), 8 (year filter), 15-16 (label country + links) | Data exists, just not wired up. High user value. |
|
||||||
|
| Medium | 1 (countries), 5 (tags browse), 6 (licenses browse), 9 (availability filter), 24 (stats) | New pages but straightforward queries. |
|
||||||
|
| Low | 2-4 (tools/features/topics), 10-13 (additional filters), 17-22 (unsurfaced datasets), 23 (discovery) | Useful but niche or requires more design work. |
|
||||||
286
docs/todo.md
Normal file
286
docs/todo.md
Normal 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.
|
||||||
21
example.env
21
example.env
@@ -12,19 +12,22 @@ PROTO=http
|
|||||||
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
|
ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb
|
||||||
|
|
||||||
# Base HTTP locations for CDN sources used by downloads.file_link
|
# Base HTTP locations for CDN sources used by downloads.file_link
|
||||||
# When file_link starts with /zxdb, it will be fetched from ZXDB_FILEPATH
|
# When file_link starts with /zxdb, it will be fetched from ZXDB_REMOTE_FILEPATH
|
||||||
ZXDB_FILEPATH=https://zxdbfiles.com/
|
ZXDB_REMOTE_FILEPATH=https://zxdbfiles.com/
|
||||||
|
|
||||||
# When file_link starts with /public, it will be fetched from WOS_FILEPATH
|
# When file_link starts with /public, it will be fetched from WOS_REMOTE_FILEPATH
|
||||||
# Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
|
# Note: Example uses the Internet Archive WoS mirror; keep the trailing slash
|
||||||
WOS_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/
|
WOS_REMOTE_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/
|
||||||
|
|
||||||
# Local cache root where files will be mirrored (without the leading slash)
|
# Local mirror filesystem paths for downloads.
|
||||||
CDN_CACHE=/mnt/files/zxfiles
|
# Enabling these (and verifying existence) will show "Local Mirror" links.
|
||||||
|
# See docs/ZXDB.md for how prefixes are stripped and joined to these paths.
|
||||||
|
# ZXDB_LOCAL_FILEPATH=/path/to/local/zxdb/mirror
|
||||||
|
# WOS_LOCAL_FILEPATH=/path/to/local/wos/mirror
|
||||||
|
|
||||||
# Optional: File prefixes for localized mirroring or rewrite logic
|
# Optional: Path prefixes to strip from database links before local matching.
|
||||||
# ZXDB_FILE_PREFIX=
|
# ZXDB_FILE_PREFIX=/zxdb/sinclair/
|
||||||
# WOS_FILE_PREFIX=
|
# WOS_FILE_PREFIX=/pub/sinclair/
|
||||||
|
|
||||||
# OIDC Authentication configuration
|
# OIDC Authentication configuration
|
||||||
# OIDC_PROVIDER_URL=
|
# OIDC_PROVIDER_URL=
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
76
src/app/api/zxdb/download/route.ts
Normal file
76
src/app/api/zxdb/download/route.ts
Normal 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";
|
||||||
@@ -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() });
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} )
|
<code>{register.hex_address}</code> ( {register.dec_address} )
|
||||||
<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 & 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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
235
src/app/zxdb/TapeIdentifier.tsx
Normal file
235
src/app/zxdb/TapeIdentifier.tsx
Normal 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(" ")} — 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
22
src/app/zxdb/actions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 (A–Z)</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 & 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>
|
{" "}—{" "}
|
||||||
</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
9
src/app/zxdb/entries/[id]/loading.tsx
Normal file
9
src/app/zxdb/entries/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
9
src/app/zxdb/genres/[id]/loading.tsx
Normal file
9
src/app/zxdb/genres/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|
||||||
|
|||||||
9
src/app/zxdb/issues/[id]/loading.tsx
Normal file
9
src/app/zxdb/issues/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`}>← 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">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
9
src/app/zxdb/labels/[id]/loading.tsx
Normal file
9
src/app/zxdb/labels/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
9
src/app/zxdb/languages/[id]/loading.tsx
Normal file
9
src/app/zxdb/languages/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
9
src/app/zxdb/machinetypes/[id]/loading.tsx
Normal file
9
src/app/zxdb/machinetypes/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|
||||||
|
|||||||
9
src/app/zxdb/magazines/[id]/loading.tsx
Normal file
9
src/app/zxdb/magazines/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">← 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 & 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 — 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{" "}—{" "}
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/app/zxdb/releases/[entryId]/[releaseSeq]/loading.tsx
Normal file
9
src/app/zxdb/releases/[entryId]/[releaseSeq]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
92
src/components/FileViewer.tsx
Normal file
92
src/components/FileViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
54
src/components/explorer/FilterSection.tsx
Normal file
54
src/components/explorer/FilterSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/components/explorer/Pagination.tsx
Normal file
67
src/components/explorer/Pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
82
src/hooks/useSearchFetch.ts
Normal file
82
src/hooks/useSearchFetch.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
11
src/middleware.ts
Normal 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
3
src/server/repo/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Barrel re-export — enables import from "@/server/repo" and
|
||||||
|
// sets up for incremental per-domain splitting.
|
||||||
|
export * from "./zxdb";
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
25
src/types/zxdb.ts
Normal 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
164
src/utils/md5.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Pure-JS MD5 for browser use (Web Crypto doesn't support MD5).
|
||||||
|
// Standard RFC 1321 implementation, typed for TypeScript.
|
||||||
|
|
||||||
|
function md5cycle(x: number[], k: number[]) {
|
||||||
|
let a = x[0], b = x[1], c = x[2], d = x[3];
|
||||||
|
|
||||||
|
a = ff(a, b, c, d, k[0], 7, -680876936);
|
||||||
|
d = ff(d, a, b, c, k[1], 12, -389564586);
|
||||||
|
c = ff(c, d, a, b, k[2], 17, 606105819);
|
||||||
|
b = ff(b, c, d, a, k[3], 22, -1044525330);
|
||||||
|
a = ff(a, b, c, d, k[4], 7, -176418897);
|
||||||
|
d = ff(d, a, b, c, k[5], 12, 1200080426);
|
||||||
|
c = ff(c, d, a, b, k[6], 17, -1473231341);
|
||||||
|
b = ff(b, c, d, a, k[7], 22, -45705983);
|
||||||
|
a = ff(a, b, c, d, k[8], 7, 1770035416);
|
||||||
|
d = ff(d, a, b, c, k[9], 12, -1958414417);
|
||||||
|
c = ff(c, d, a, b, k[10], 17, -42063);
|
||||||
|
b = ff(b, c, d, a, k[11], 22, -1990404162);
|
||||||
|
a = ff(a, b, c, d, k[12], 7, 1804603682);
|
||||||
|
d = ff(d, a, b, c, k[13], 12, -40341101);
|
||||||
|
c = ff(c, d, a, b, k[14], 17, -1502002290);
|
||||||
|
b = ff(b, c, d, a, k[15], 22, 1236535329);
|
||||||
|
|
||||||
|
a = gg(a, b, c, d, k[1], 5, -165796510);
|
||||||
|
d = gg(d, a, b, c, k[6], 9, -1069501632);
|
||||||
|
c = gg(c, d, a, b, k[11], 14, 643717713);
|
||||||
|
b = gg(b, c, d, a, k[0], 20, -373897302);
|
||||||
|
a = gg(a, b, c, d, k[5], 5, -701558691);
|
||||||
|
d = gg(d, a, b, c, k[10], 9, 38016083);
|
||||||
|
c = gg(c, d, a, b, k[15], 14, -660478335);
|
||||||
|
b = gg(b, c, d, a, k[4], 20, -405537848);
|
||||||
|
a = gg(a, b, c, d, k[9], 5, 568446438);
|
||||||
|
d = gg(d, a, b, c, k[14], 9, -1019803690);
|
||||||
|
c = gg(c, d, a, b, k[3], 14, -187363961);
|
||||||
|
b = gg(b, c, d, a, k[8], 20, 1163531501);
|
||||||
|
a = gg(a, b, c, d, k[13], 5, -1444681467);
|
||||||
|
d = gg(d, a, b, c, k[2], 9, -51403784);
|
||||||
|
c = gg(c, d, a, b, k[7], 14, 1735328473);
|
||||||
|
b = gg(b, c, d, a, k[12], 20, -1926607734);
|
||||||
|
|
||||||
|
a = hh(a, b, c, d, k[5], 4, -378558);
|
||||||
|
d = hh(d, a, b, c, k[8], 11, -2022574463);
|
||||||
|
c = hh(c, d, a, b, k[11], 16, 1839030562);
|
||||||
|
b = hh(b, c, d, a, k[14], 23, -35309556);
|
||||||
|
a = hh(a, b, c, d, k[1], 4, -1530992060);
|
||||||
|
d = hh(d, a, b, c, k[4], 11, 1272893353);
|
||||||
|
c = hh(c, d, a, b, k[7], 16, -155497632);
|
||||||
|
b = hh(b, c, d, a, k[10], 23, -1094730640);
|
||||||
|
a = hh(a, b, c, d, k[13], 4, 681279174);
|
||||||
|
d = hh(d, a, b, c, k[0], 11, -358537222);
|
||||||
|
c = hh(c, d, a, b, k[3], 16, -722521979);
|
||||||
|
b = hh(b, c, d, a, k[6], 23, 76029189);
|
||||||
|
a = hh(a, b, c, d, k[9], 4, -640364487);
|
||||||
|
d = hh(d, a, b, c, k[12], 11, -421815835);
|
||||||
|
c = hh(c, d, a, b, k[15], 16, 530742520);
|
||||||
|
b = hh(b, c, d, a, k[2], 23, -995338651);
|
||||||
|
|
||||||
|
a = ii(a, b, c, d, k[0], 6, -198630844);
|
||||||
|
d = ii(d, a, b, c, k[7], 10, 1126891415);
|
||||||
|
c = ii(c, d, a, b, k[14], 15, -1416354905);
|
||||||
|
b = ii(b, c, d, a, k[5], 21, -57434055);
|
||||||
|
a = ii(a, b, c, d, k[12], 6, 1700485571);
|
||||||
|
d = ii(d, a, b, c, k[3], 10, -1894986606);
|
||||||
|
c = ii(c, d, a, b, k[10], 15, -1051523);
|
||||||
|
b = ii(b, c, d, a, k[1], 21, -2054922799);
|
||||||
|
a = ii(a, b, c, d, k[8], 6, 1873313359);
|
||||||
|
d = ii(d, a, b, c, k[15], 10, -30611744);
|
||||||
|
c = ii(c, d, a, b, k[6], 15, -1560198380);
|
||||||
|
b = ii(b, c, d, a, k[13], 21, 1309151649);
|
||||||
|
a = ii(a, b, c, d, k[4], 6, -145523070);
|
||||||
|
d = ii(d, a, b, c, k[11], 10, -1120210379);
|
||||||
|
c = ii(c, d, a, b, k[2], 15, 718787259);
|
||||||
|
b = ii(b, c, d, a, k[9], 21, -343485551);
|
||||||
|
|
||||||
|
x[0] = add32(a, x[0]);
|
||||||
|
x[1] = add32(b, x[1]);
|
||||||
|
x[2] = add32(c, x[2]);
|
||||||
|
x[3] = add32(d, x[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
|
||||||
|
a = add32(add32(a, q), add32(x, t));
|
||||||
|
return add32((a << s) | (a >>> (32 - s)), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & c) | (~b & d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & d) | (c & ~d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(b ^ c ^ d, a, b, x, s, t);
|
||||||
|
}
|
||||||
|
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(c ^ (b | ~d), a, b, x, s, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function add32(a: number, b: number) {
|
||||||
|
return (a + b) & 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5blk(s: Uint8Array, offset: number): number[] {
|
||||||
|
const md5blks: number[] = [];
|
||||||
|
for (let i = 0; i < 64; i += 4) {
|
||||||
|
md5blks[i >> 2] =
|
||||||
|
s[offset + i] +
|
||||||
|
(s[offset + i + 1] << 8) +
|
||||||
|
(s[offset + i + 2] << 16) +
|
||||||
|
(s[offset + i + 3] << 24);
|
||||||
|
}
|
||||||
|
return md5blks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = "0123456789abcdef".split("");
|
||||||
|
|
||||||
|
function rhex(n: number) {
|
||||||
|
let s = "";
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
s += hex[(n >> (j * 8 + 4)) & 0x0f] + hex[(n >> (j * 8)) & 0x0f];
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5raw(bytes: Uint8Array): string {
|
||||||
|
const n = bytes.length;
|
||||||
|
const state = [1732584193, -271733879, -1732584194, 271733878];
|
||||||
|
|
||||||
|
let i: number;
|
||||||
|
for (i = 64; i <= n; i += 64) {
|
||||||
|
md5cycle(state, md5blk(bytes, i - 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail: copy remaining bytes into a padded buffer
|
||||||
|
const tail = new Uint8Array(64);
|
||||||
|
const remaining = n - (i - 64);
|
||||||
|
for (let j = 0; j < remaining; j++) {
|
||||||
|
tail[j] = bytes[i - 64 + j];
|
||||||
|
}
|
||||||
|
tail[remaining] = 0x80;
|
||||||
|
|
||||||
|
// If remaining >= 56 we need an extra block
|
||||||
|
if (remaining >= 56) {
|
||||||
|
md5cycle(state, md5blk(tail, 0));
|
||||||
|
tail.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append bit length as 64-bit little-endian
|
||||||
|
const bitLen = n * 8;
|
||||||
|
tail[56] = bitLen & 0xff;
|
||||||
|
tail[57] = (bitLen >> 8) & 0xff;
|
||||||
|
tail[58] = (bitLen >> 16) & 0xff;
|
||||||
|
tail[59] = (bitLen >> 24) & 0xff;
|
||||||
|
// For files < 512 MB the high 32 bits are 0; safe for tape images
|
||||||
|
md5cycle(state, md5blk(tail, 0));
|
||||||
|
|
||||||
|
return rhex(state[0]) + rhex(state[1]) + rhex(state[2]) + rhex(state[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a File as ArrayBuffer and returns its MD5 hex digest.
|
||||||
|
export async function computeMd5(file: File): Promise<string> {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
return md5raw(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
29
src/utils/params.ts
Normal file
29
src/utils/params.ts
Normal 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();
|
||||||
|
}
|
||||||
93
src/utils/register_helpers.ts
Normal file
93
src/utils/register_helpers.ts
Normal 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
Reference in New Issue
Block a user