react-bootstrap migration, review & parser fixes

UI / react-bootstrap:
Migrate client components to react-bootstrap (Card, Table, Form,
Alert, Badge, Nav, Button, Spinner, Row, Col): the ZXDB explorers
and detail pages (Labels, Genres, Languages, MachineTypes, Releases,
Entries), TapeIdentifier, home page, Navbar and ThemeDropdown.
Server components (home, zxdb hub, magazines, issues) keep raw
HTML+className — react-bootstrap barrel imports resolve to undefined
under Turbopack in server components. Replace bi bi-* CSS icons with
react-bootstrap-icons. Add aria-labels to search inputs and
visually-hidden captions to data tables.

Code-review remediation (docs/todo.md):
- FileViewer: replace useState-as-effect with a proper useEffect.
- register.service: restore request-level caching of parsed registers.
- middleware: convert .js to .ts, dev-only request logging.
- Extract shared types to src/types/zxdb.ts; add src/server/repo
  barrel for incremental per-domain splitting.
- Extract helpers: parseIdList (params.ts), serialize (serialize.ts),
  buildRegisterSummary/isInfoLine (register_helpers.ts).
- Add loading.tsx skeletons for dynamic ZXDB detail routes.
- generateMetadata + notFound() on entry/release/label detail pages.
- opengraph-image: stable keys; ThemeDropdown: drop hardcoded cookie
  domain; remove unused page.module.css.

Register parser & data:
- Update data/nextreg.txt from upstream tbblue (SpectrumNext FPGA):
  0x04/0x0A/0x0F/0x80/0x81 bit changes, new Issue 5 board id, 0x43
  renamed "Palette Control", 0xF0/0xF8/0xF9/0xFA now "Issues 4 and 5
  Only".
- Add reg_44 custom parser for 0x44 (Palette Value 9-bit): the two
  consecutive writes render as separate "1st write" / "2nd write"
  modes.
- Skip commented-out register headers so the disabled 0xA3 block no
  longer leaks a phantom register.
- Add detailHasContent guard so body-less registers (0xC7/0xCB/0xCF/
  0xFF) and 0xF0's leading blank no longer emit empty tab strips.
- Capture 0xF0's leading "Issues 4 and 5 Only" line as register text.
- Add isIssueRestricted (case-sensitive) to detect the issue badge
  across rewording without flagging per-bit "(issue 5 only)" notes;
  update badge label to "Issues 4 & 5 Only".

claude-opus-4-8@lucy
This commit is contained in:
2026-06-08 22:47:37 +01:00
parent 590b07147a
commit e803274af4
79 changed files with 1346 additions and 1011 deletions

View File

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

286
docs/todo.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,11 @@ export async function GET(req: NextRequest) {
return new NextResponse("Invalid source or mirroring not enabled", { status: 400 }); 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 // Security: Ensure path doesn't escape baseDir
const absolutePath = path.normalize(path.join(baseDir, filePath)); const absolutePath = path.normalize(path.join(baseDir, filePath));
if (!absolutePath.startsWith(path.normalize(baseDir))) { if (!absolutePath.startsWith(path.normalize(baseDir))) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { searchReleases } from "@/server/repo/zxdb"; import { searchReleases } from "@/server/repo";
import { parseIdList } from "@/utils/params";
const querySchema = z.object({ const querySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@@ -17,15 +18,6 @@ const querySchema = z.object({
isDemo: z.coerce.boolean().optional(), isDemo: z.coerce.boolean().optional(),
}); });
function parseIdList(value: string | undefined) {
if (!value) return undefined;
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({ const parsed = querySchema.safeParse({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,11 @@
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import Link from "next/link"; 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 { computeMd5 } from "@/utils/md5";
import { identifyTape } from "./actions"; import { identifyTape } from "./actions";
import type { TapeMatch } from "@/server/repo/zxdb"; import type { TapeMatch } from "@/server/repo";
const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"]; const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
@@ -79,12 +81,12 @@ export default function TapeIdentifier() {
// Dropzone view (idle, hashing, identifying, error) // Dropzone view (idle, hashing, identifying, error)
if (state.kind === "results" || state.kind === "not-found") { if (state.kind === "results" || state.kind === "not-found") {
return ( return (
<div className="card border-0 shadow-sm"> <Card className="border-0 shadow-sm">
<div className="card-body"> <Card.Body>
<h5 className="card-title d-flex align-items-center gap-2 mb-3"> <Card.Title className="d-flex align-items-center gap-2 mb-3">
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden /> <Cassette size={22} aria-hidden />
Tape Identifier Tape Identifier
</h5> </Card.Title>
{state.kind === "results" ? ( {state.kind === "results" ? (
<> <>
@@ -92,34 +94,34 @@ export default function TapeIdentifier() {
<strong>{state.fileName}</strong> matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}: <strong>{state.fileName}</strong> matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:
</p> </p>
{state.matches.map((m) => ( {state.matches.map((m) => (
<div key={m.downloadId} className="card border mb-3"> <Card key={m.downloadId} className="border mb-3">
<div className="card-body"> <Card.Body>
<div className="d-flex justify-content-between align-items-start mb-2"> <div className="d-flex justify-content-between align-items-start mb-2">
<h6 className="card-title mb-0"> <Card.Title as="h6" className="mb-0">
<Link href={`/zxdb/entries/${m.entryId}`} className="text-decoration-none"> <Link href={`/zxdb/entries/${m.entryId}`} className="text-decoration-none">
{m.entryTitle} {m.entryTitle}
</Link> </Link>
</h6> </Card.Title>
{m.releaseYear && ( {m.releaseYear && (
<span className="badge text-bg-secondary ms-2">{m.releaseYear}</span> <Badge bg="secondary" className="ms-2">{m.releaseYear}</Badge>
)} )}
</div> </div>
{(m.authors.length > 0 || m.genre || m.machinetype) && ( {(m.authors.length > 0 || m.genre || m.machinetype) && (
<div className="d-flex flex-wrap gap-2 mb-2 small text-secondary"> <div className="d-flex flex-wrap gap-2 mb-2 small text-secondary">
{m.authors.length > 0 && ( {m.authors.length > 0 && (
<span><span className="bi bi-person me-1" aria-hidden />{m.authors.join(", ")}</span> <span><PersonFill className="me-1" aria-hidden />{m.authors.join(", ")}</span>
)} )}
{m.genre && ( {m.genre && (
<span><span className="bi bi-tag me-1" aria-hidden />{m.genre}</span> <span><TagFill className="me-1" aria-hidden />{m.genre}</span>
)} )}
{m.machinetype && ( {m.machinetype && (
<span><span className="bi bi-cpu me-1" aria-hidden />{m.machinetype}</span> <span><CpuFill className="me-1" aria-hidden />{m.machinetype}</span>
)} )}
</div> </div>
)} )}
<table className="table table-sm table-borderless mb-2 small" style={{ maxWidth: 500 }}> <Table size="sm" borderless className="mb-2 small" style={{ maxWidth: 500 }}>
<tbody> <tbody>
<tr> <tr>
<td className="text-secondary ps-0" style={{ width: 90 }}>File</td> <td className="text-secondary ps-0" style={{ width: 90 }}>File</td>
@@ -138,16 +140,16 @@ export default function TapeIdentifier() {
<td className="font-monospace">{m.crc32}</td> <td className="font-monospace">{m.crc32}</td>
</tr> </tr>
</tbody> </tbody>
</table> </Table>
<Link <Link
href={`/zxdb/entries/${m.entryId}`} href={`/zxdb/entries/${m.entryId}`}
className="btn btn-outline-primary btn-sm" className="btn btn-outline-primary btn-sm"
> >
View entry <span className="bi bi-arrow-right ms-1" aria-hidden /> View entry <ArrowRight className="ms-1" aria-hidden />
</Link> </Link>
</div> </Card.Body>
</div> </Card>
))} ))}
</> </>
) : ( ) : (
@@ -156,23 +158,23 @@ export default function TapeIdentifier() {
</p> </p>
)} )}
<button className="btn btn-outline-primary btn-sm" onClick={reset}> <Button variant="outline-primary" size="sm" onClick={reset}>
Identify another tape Identify another tape
</button> </Button>
</div> </Card.Body>
</div> </Card>
); );
} }
const isProcessing = state.kind === "hashing" || state.kind === "identifying"; const isProcessing = state.kind === "hashing" || state.kind === "identifying";
return ( return (
<div className="card border-0 shadow-sm"> <Card className="border-0 shadow-sm">
<div className="card-body"> <Card.Body>
<h5 className="card-title d-flex align-items-center gap-2 mb-3"> <Card.Title className="d-flex align-items-center gap-2 mb-3">
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden /> <Cassette size={22} aria-hidden />
Tape Identifier Tape Identifier
</h5> </Card.Title>
<div <div
className={`rounded-3 p-4 text-center ${dragOver ? "bg-primary bg-opacity-10 border-primary" : "border-secondary border-opacity-25"}`} className={`rounded-3 p-4 text-center ${dragOver ? "bg-primary bg-opacity-10 border-primary" : "border-secondary border-opacity-25"}`}
@@ -191,7 +193,7 @@ export default function TapeIdentifier() {
> >
{isProcessing ? ( {isProcessing ? (
<div className="py-2"> <div className="py-2">
<div className="spinner-border spinner-border-sm text-primary me-2" role="status" /> <Spinner animation="border" size="sm" variant="primary" className="me-2" />
<span className="text-secondary"> <span className="text-secondary">
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"} {state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
</span> </span>
@@ -199,7 +201,7 @@ export default function TapeIdentifier() {
) : ( ) : (
<> <>
<div className="mb-2"> <div className="mb-2">
<span className="bi bi-cloud-arrow-up" style={{ fontSize: 32, opacity: 0.5 }} aria-hidden /> <CloudArrowUp size={32} style={{ opacity: 0.5 }} aria-hidden />
</div> </div>
<p className="mb-1 text-secondary"> <p className="mb-1 text-secondary">
Drop a tape file to identify it Drop a tape file to identify it
@@ -223,11 +225,11 @@ export default function TapeIdentifier() {
</div> </div>
{state.kind === "error" && ( {state.kind === "error" && (
<div className="alert alert-warning mt-3 mb-0 py-2 small"> <Alert variant="warning" className="mt-3 mb-0 py-2 small">
{state.message} {state.message}
</div> </Alert>
)} )}
</div> </Card.Body>
</div> </Card>
); );
} }

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { lookupByMd5, type TapeMatch } from "@/server/repo/zxdb"; import { lookupByMd5, type TapeMatch } from "@/server/repo";
export async function identifyTape( export async function identifyTape(
md5: string, md5: string,

View File

@@ -13,8 +13,9 @@ 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 Pagination from "@/components/explorer/Pagination";
import useSearchFetch from "@/hooks/useSearchFetch"; 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; const PAGE_SIZE = 20;
type Item = { type Item = {
@@ -29,31 +30,6 @@ type Item = {
languageName?: string | null; languageName?: string | null;
}; };
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
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 };
};
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();
}
export default function EntriesExplorer({ export default function EntriesExplorer({
initial, initial,
initialGenres, initialGenres,
@@ -62,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 }[];
@@ -74,7 +50,7 @@ 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 router = useRouter(); const router = useRouter();
@@ -90,7 +66,7 @@ export default function EntriesExplorer({
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);
// -- Filter lists -- // -- Filter lists --
@@ -314,7 +290,7 @@ export default function EntriesExplorer({
<option value="title">Title (A-Z)</option> <option value="title">Title (A-Z)</option>
<option value="id_desc">Newest</option> <option value="id_desc">Newest</option>
</Form.Select> </Form.Select>
<Form.Select size="sm" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}> <Form.Select size="sm" value={scope} onChange={(e) => { setScope(e.target.value as EntrySearchScope); setPage(1); }}>
<option value="title">Titles only</option> <option value="title">Titles only</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>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import EntriesExplorer from "./EntriesExplorer"; import EntriesExplorer from "./EntriesExplorer";
import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb"; import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo";
import { parseIdList } from "@/utils/params";
export const metadata = { export const metadata = {
title: "ZXDB Entries", title: "ZXDB Entries",
@@ -7,16 +8,6 @@ export const metadata = {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function parseIdList(value: string | string[] | undefined) {
if (!value) return undefined;
const raw = Array.isArray(value) ? value.join(",") : value;
const ids = raw
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams; const sp = await searchParams;
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);

View File

@@ -3,16 +3,18 @@
import { useCallback, 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 Pagination from "@/components/explorer/Pagination";
type Genre = { id: number; name: string }; import type { PagedResult } from "@/types/zxdb";
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Genre>; initialQ?: string }) { type Genre = { id: number; name: 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(() => {
@@ -54,49 +56,48 @@ 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>
<Pagination <Pagination
page={data?.page ?? 1} page={data?.page ?? 1}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,18 @@
import { useCallback, 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 Pagination from "@/components/explorer/Pagination";
type Label = { id: number; name: string; labeltypeId: string | null }; import type { PagedResult } from "@/types/zxdb";
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<Label>; initialQ?: string }) { type Label = { id: number; name: string; labeltypeId: string | null };
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]);
useEffect(() => { useEffect(() => {
@@ -54,53 +56,52 @@ 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>
<Pagination <Pagination
page={data?.page ?? 1} page={data?.page ?? 1}

View File

@@ -4,6 +4,7 @@ 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;
@@ -30,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.
@@ -43,24 +45,24 @@ 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) && ( {(initial.label.countryId || initial.label.linkWikipedia || initial.label.linkSite) && (
@@ -82,140 +84,130 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
</div> </div>
)} )}
<div className="row g-4 mt-1"> <Row className="g-4 mt-1">
<div className="col-lg-6"> <Col lg={6}>
<h5>Permissions</h5> <h5>Permissions</h5>
{initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>} {initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>}
{initial.label.permissions.length > 0 && ( {initial.label.permissions.length > 0 && (
<div className="table-responsive"> <Table size="sm" striped className="align-middle">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 140 }}>Type</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{initial.label.permissions.map((p, idx) => (
<tr key={`${p.website.id}-${p.type.id}-${idx}`}>
<td>
{p.website.link ? (
<a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
) : (
<span>{p.website.name}</span>
)}
</td>
<td>{p.type.name ?? p.type.id}</td>
<td>{p.text ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="col-lg-6">
<h5>Licenses</h5>
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
{initial.label.licenses.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th style={{ width: 140 }}>Type</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{initial.label.licenses.map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td>{l.type.name ?? l.type.id}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{l.linkWikipedia && (
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
)}
{l.linkSite && (
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
)}
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<ul className="nav nav-tabs mt-3">
<li className="nav-item">
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
</li>
<li className="nav-item">
<button className={`nav-link ${tab === "published" ? "active" : ""}`} onClick={() => setTab("published")}>Published</button>
</li>
</ul>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder={`Search within ${tab}`} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style={{ width: 80 }}>ID</th> <th>Website</th>
<th>Title</th> <th style={{ width: 140 }}>Type</th>
<th style={{ width: 160 }}>Machine</th> <th>Notes</th>
<th style={{ width: 120 }}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{current.items.map((it) => ( {initial.label.permissions.map((p, idx) => (
<tr key={it.id}> <tr key={`${p.website.id}-${p.type.id}-${idx}`}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td> <td>
{it.machinetypeId != null ? ( {p.website.link ? (
it.machinetypeName ? ( <a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : ( ) : (
<span className="text-secondary">-</span> <span>{p.website.name}</span>
)} )}
</td> </td>
<td>{p.type.name ?? p.type.id}</td>
<td>{p.text ?? ""}</td>
</tr>
))}
</tbody>
</Table>
)}
</Col>
<Col lg={6}>
<h5>Licenses</h5>
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
{initial.label.licenses.length > 0 && (
<Table size="sm" striped className="align-middle">
<thead>
<tr>
<th>Name</th>
<th style={{ width: 140 }}>Type</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{initial.label.licenses.map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td>{l.type.name ?? l.type.id}</td>
<td> <td>
{it.languageId ? ( <div className="d-flex gap-2 flex-wrap">
it.languageName ? ( {l.linkWikipedia && (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link> <a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
) : ( )}
<span>{it.languageId}</span> {l.linkSite && (
) <a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
) : ( )}
<span className="text-secondary">-</span> {!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
)} </div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </Table>
</div> )}
</Col>
</Row>
<Nav variant="tabs" className="mt-3" activeKey={tab} onSelect={(k) => setTab(k as "authored" | "published")}>
<Nav.Item>
<Nav.Link eventKey="authored">Authored</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="published">Published</Nav.Link>
</Nav.Item>
</Nav>
<Form className="d-flex align-items-center gap-2 mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}>
<Form.Control className="w-auto" style={{ minWidth: 200 }} placeholder={`Search within ${tab}`} aria-label={`Search within ${tab}`} value={q} onChange={(e) => setQ(e.target.value)} />
<Button variant="primary" type="submit">Search</Button>
</Form>
<div className="mt-3">
{current && current.items.length === 0 && <Alert variant="warning">No entries.</Alert>}
{current && current.items.length > 0 && (
<Table striped hover className="align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
{current.items.map((it) => (
<tr key={it.id}>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</Table>
)} )}
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,18 @@
import { useCallback, 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 Pagination from "@/components/explorer/Pagination";
type Language = { id: string; name: string }; import type { PagedResult } from "@/types/zxdb";
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged<Language>; initialQ?: string }) { type Language = { id: string; name: 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(() => {
@@ -54,49 +56,48 @@ 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>
<Pagination <Pagination
page={data?.page ?? 1} page={data?.page ?? 1}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,18 @@
import { useCallback, 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 Pagination from "@/components/explorer/Pagination";
type MT = { id: number; name: string }; import type { PagedResult } from "@/types/zxdb";
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged<MT>; initialQ?: string }) { type MT = { id: number; name: 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]);
useEffect(() => { useEffect(() => {
@@ -54,49 +56,48 @@ 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>
<Pagination <Pagination
page={data?.page ?? 1} page={data?.page ?? 1}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Collection, BoxArrowDown, JournalText, People } from "react-bootstrap-icons";
import TapeIdentifier from "./TapeIdentifier"; import TapeIdentifier from "./TapeIdentifier";
export const metadata = { export const metadata = {
@@ -19,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">
@@ -30,14 +31,14 @@ export default async function Page() {
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/entries">Browse entries</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/entries">Browse entries</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/releases">Latest releases</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/releases">Latest releases</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">Magazine issues</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">Magazine issues</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">People & labels</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">People &amp; labels</Link>
</div> </div>
</div> </div>
<div className="col-lg-5"> <div className="col-lg-5">
<div className="card border-0 shadow-sm"> <div className="card border-0 shadow-sm">
<div className="card-body"> <div className="card-body">
<h5 className="card-title mb-3">Jump straight in</h5> <h5 className="card-title mb-3">Jump straight in</h5>
<form className="d-flex flex-column gap-2" method="get" action="/zxdb/entries"> <form method="get" action="/zxdb/entries" className="d-flex flex-column gap-2">
<div> <div>
<label className="form-label small text-secondary">Search entries</label> <label className="form-label small text-secondary">Search entries</label>
<input className="form-control" name="q" placeholder="Try: manic, doom, renegade..." /> <input className="form-control" name="q" placeholder="Try: manic, doom, renegade..." />
@@ -50,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>
@@ -58,15 +59,17 @@ export default async function Page() {
</div> </div>
</section> </section>
<section className="row g-3"> <section>
<div className="col-lg-8"> <div className="row g-3">
<TapeIdentifier /> <div className="col-lg-8">
</div> <TapeIdentifier />
<div className="col-lg-4 d-flex align-items-center"> </div>
<p className="text-secondary small mb-0"> <div className="col-lg-4 d-flex align-items-center">
Drop a <code>.tap</code>, <code>.tzx</code>, or other tape file to identify it against 32,000+ ZXDB entries. <p className="text-secondary small mb-0">
The file stays in your browser &mdash; only its hash is sent. Drop a <code>.tap</code>, <code>.tzx</code>, or other tape file to identify it against 32,000+ ZXDB entries.
</p> The file stays in your browser &mdash; only its hash is sent.
</p>
</div>
</div> </div>
</section> </section>
@@ -81,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>
@@ -96,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>
@@ -111,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>
@@ -126,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>
@@ -139,31 +142,33 @@ export default async function Page() {
</div> </div>
</section> </section>
<section className="row g-3"> <section>
<div className="col-lg-7"> <div className="row g-3">
<div className="card h-100 shadow-sm"> <div className="col-lg-7">
<div className="card-body"> <div className="card h-100 shadow-sm">
<h3 className="h5">Explore by category</h3> <div className="card-body">
<p className="text-secondary mb-3">Jump to curated lists and filter results from there.</p> <h3 className="h5">Explore by category</h3>
<div className="d-flex flex-wrap gap-2"> <p className="text-secondary mb-3">Jump to curated lists and filter results from there.</p>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link> <div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link> <Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div className="col-lg-5">
<div className="col-lg-5"> <div className="card h-100 shadow-sm">
<div className="card h-100 shadow-sm"> <div className="card-body">
<div className="card-body"> <h3 className="h5">How to use this</h3>
<h3 className="h5">How to use this</h3> <ol className="mb-0 text-secondary small">
<ol className="mb-0 text-secondary small"> <li>Search by title or aliases in Entries.</li>
<li>Search by title or aliases in Entries.</li> <li>Open a release to see downloads, scraps, and places.</li>
<li>Open a release to see downloads, scraps, and places.</li> <li>Use magazines to find original reviews and references.</li>
<li>Use magazines to find original reviews and references.</li> <li>Follow labels to discover related work.</li>
<li>Follow labels to discover related work.</li> </ol>
</ol> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,19 +13,11 @@ 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 Pagination from "@/components/explorer/Pagination";
import useSearchFetch from "@/hooks/useSearchFetch"; 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; 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;
releaseSeq: number; releaseSeq: number;
@@ -34,19 +26,12 @@ 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,
initialLists, initialLists,
}: { }: {
initial?: Paged<Item>; initial?: PagedResult<Item>;
initialUrlState?: { initialUrlState?: {
q: string; q: string;
page: number; page: number;

View File

@@ -2,6 +2,7 @@
import { useState, useMemo } from "react"; 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"; import FileViewer from "@/components/FileViewer";
@@ -198,7 +199,7 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0])); return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [data?.scraps]); }, [data?.scraps]);
if (!data) return <div className="alert alert-warning">Not found</div>; 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);
@@ -216,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>
@@ -292,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">
@@ -350,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>
@@ -370,26 +368,24 @@ 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>
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>} {groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{groupedDownloads.map(([type, items]) => ( {groupedDownloads.map(([type, items]) => (
<div key={type} className="mb-4"> <div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6> <h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<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>Link</th> <th>Link</th>
@@ -458,22 +454,20 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
); );
})} })}
</tbody> </tbody>
</table> </Table>
</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">Scraps / Media</h5> <Card.Title>Scraps / Media</Card.Title>
{groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>} {groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>}
{groupedScraps.map(([type, items]) => ( {groupedScraps.map(([type, items]) => (
<div key={type} className="mb-4"> <div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6> <h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<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>Link</th> <th>Link</th>
@@ -544,56 +538,53 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
); );
})} })}
</tbody> </tbody>
</table> </Table>
</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">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>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Modal, Button, Spinner } from "react-bootstrap"; import { Modal, Button, Spinner } from "react-bootstrap";
type FileViewerProps = { type FileViewerProps = {
@@ -20,8 +20,10 @@ export default function FileViewer({ url, title, onClose }: FileViewerProps) {
const viewUrl = url.includes("?") ? `${url}&view=1` : `${url}?view=1`; const viewUrl = url.includes("?") ? `${url}&view=1` : `${url}?view=1`;
useState(() => { useEffect(() => {
if (isText) { if (isText) {
setLoading(true);
setError(null);
fetch(viewUrl) fetch(viewUrl)
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("Failed to load file"); if (!res.ok) throw new Error("Failed to load file");
@@ -38,7 +40,7 @@ export default function FileViewer({ url, title, onClose }: FileViewerProps) {
} else { } else {
setLoading(false); setLoading(false);
} }
}); }, [viewUrl, isText]);
return ( return (
<Modal show size="xl" onHide={onClose} centered scrollable> <Modal show size="xl" onHide={onClose} centered scrollable>

View File

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

View File

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

View File

@@ -1,13 +1,7 @@
"use client"; "use client";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import type { PagedResult } from "@/types/zxdb";
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
/** /**
* Manages API search fetching with automatic request cancellation * Manages API search fetching with automatic request cancellation
@@ -19,14 +13,16 @@ type Paged<T> = {
*/ */
export default function useSearchFetch<T>( export default function useSearchFetch<T>(
endpoint: string, endpoint: string,
initialData: Paged<T> | null = null, initialData: PagedResult<T> | null = null,
onExtra?: (json: Record<string, unknown>) => void, onExtra?: (json: Record<string, unknown>) => void,
) { ) {
const [data, setData] = useState<Paged<T> | null>(initialData); const [data, setData] = useState<PagedResult<T> | null>(initialData);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const onExtraRef = useRef(onExtra);
onExtraRef.current = onExtra;
const fetch_ = useCallback( const fetch_ = useCallback(
async (params: URLSearchParams) => { async (params: URLSearchParams) => {
@@ -55,7 +51,7 @@ export default function useSearchFetch<T>(
pageSize: json.pageSize, pageSize: json.pageSize,
total: json.total, total: json.total,
}); });
onExtra?.(json); onExtraRef.current?.(json);
} }
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") return; if (e instanceof DOMException && e.name === "AbortError") return;
@@ -71,11 +67,11 @@ export default function useSearchFetch<T>(
} }
} }
}, },
[endpoint, onExtra], [endpoint],
); );
// Allow syncing SSR data without a fetch // Allow syncing SSR data without a fetch
const syncData = useCallback((d: Paged<T>) => { const syncData = useCallback((d: PagedResult<T>) => {
abortRef.current?.abort(); abortRef.current?.abort();
setData(d); setData(d);
setLoading(false); setLoading(false);

View File

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

11
src/middleware.ts Normal file
View File

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

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

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

View File

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

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

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

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

@@ -0,0 +1,29 @@
/**
* Parse a comma-separated string of positive integer IDs.
* Accepts either a plain string or a string[] (from searchParams).
*/
export function parseIdList(value: string | string[] | undefined): number[] | undefined {
if (!value) return undefined;
const raw = Array.isArray(value) ? value.join(",") : value;
const ids = raw
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : undefined;
}
/** Default machine type IDs for entry/release searches */
export const preferredMachineIds = [27, 26, 8, 9];
/**
* Parse machine type IDs from a comma-separated string,
* falling back to preferredMachineIds when empty.
*/
export function parseMachineIds(value?: string): number[] {
if (!value) return preferredMachineIds.slice();
const ids = value
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isFinite(id) && id > 0);
return ids.length ? ids : preferredMachineIds.slice();
}

View File

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

View File

@@ -1,5 +1,6 @@
import { parseDescriptionDefault } from "./register_parsers/reg_default"; import { parseDescriptionDefault } from "./register_parsers/reg_default";
import { parseDescriptionF0 } from "./register_parsers/reg_f0"; import { parseDescriptionF0 } from "./register_parsers/reg_f0";
import { parseDescription44 } from "./register_parsers/reg_44";
export interface RegisterBitwiseOperation { export interface RegisterBitwiseOperation {
bits: string; bits: string;
@@ -41,12 +42,43 @@ export interface Register {
notes: Note[]; notes: Note[];
} }
/**
* Detects a source line marking the whole register as restricted to certain
* board issues, e.g. "Issue 4 Only" or "Issues 4 and 5 Only". Matched loosely
* across wording so upstream rewording (the tbblue source has used both forms)
* keeps setting the flag rather than silently dropping the badge.
*
* Case-sensitive on purpose: register-level markers are capitalised, while an
* incidental per-bit caveat like "(issue 5 only)" (e.g. nextreg 0x81 bit 3) is
* lowercase and must not flag the entire register.
*/
export function isIssueRestricted(line: string): boolean {
return /Issues?\b.*\bOnly/.test(line);
}
/**
* True when a parsed RegisterDetail carries something worth rendering — a mode
* name, an access block, or mode-level text. Body-less registers (e.g. the
* "Reserved" entries 0xC7/0xCB/0xCF/0xFF) would otherwise contribute an empty
* mode that renders as a stray, contentless tab strip. Parsers call this before
* pushing a detail into reg.modes.
*/
export function detailHasContent(detail: RegisterDetail): boolean {
return Boolean(
detail.modeName ||
detail.read ||
detail.write ||
detail.common ||
(detail.text && detail.text.trim())
);
}
/** /**
* Parses the content of the nextreg.txt file and returns an array of register objects. * Parses the content of the nextreg.txt file and returns an array of register objects.
* @param fileContent The content of the nextreg.txt file. * @param fileContent The content of the nextreg.txt file.
* @returns A promise that resolves to an array of Register objects. * @returns A promise that resolves to an array of Register objects.
*/ */
export async function parseNextReg(fileContent: string): Promise<Register[]> { export function parseNextReg(fileContent: string): Register[] {
const registers: Register[] = []; const registers: Register[] = [];
const paragraphs = fileContent.split(/\n\s*\n/); const paragraphs = fileContent.split(/\n\s*\n/);
@@ -64,6 +96,13 @@ export function processRegisterBlock(paragraph: string, registers: Register[]) {
const lines = paragraph.trim().split('\n'); const lines = paragraph.trim().split('\n');
const firstLine = lines[0]; const firstLine = lines[0];
// Skip commented-out register blocks. The header regex below is not anchored,
// so a disabled entry like "// 0xA3 (163) => ..." would otherwise match and
// leak a phantom register into the output.
if (firstLine.trim().startsWith('//')) {
return;
}
const registerMatch = firstLine.match(/([0-9a-fA-F,x]+)\s*\((.*?)\)\s*=>\s*(.*)/); const registerMatch = firstLine.match(/([0-9a-fA-F,x]+)\s*\((.*?)\)\s*=>\s*(.*)/);
if (!registerMatch) { if (!registerMatch) {
@@ -134,6 +173,9 @@ export function processRegisterBlock(paragraph: string, registers: Register[]) {
case '0xF0': case '0xF0':
parseDescriptionF0(reg, description); parseDescriptionF0(reg, description);
break; break;
case '0x44':
parseDescription44(reg, description);
break;
default: default:
parseDescriptionDefault(reg, description); parseDescriptionDefault(reg, description);
break; break;

View File

@@ -0,0 +1,91 @@
// Special-case parser for 0x44 (Palette Value, 9 bit colour).
// The 9-bit colour is written with two consecutive byte writes, each carrying
// its own bit layout. The source nests these under "1st write:" / "2nd write:"
// headers (two leading spaces, trailing colon) with their bit definitions
// indented beneath (four leading spaces):
//
// (R/W)
// Two consecutive writes are needed to write the 9 bit colour
// 1st write:
// bits 7:0 = RRRGGGBB
// 2nd write:
// bits 7:1 = Reserved, must be 0
// ...
//
// Each write becomes its own mode with a single Read/Write access block so the
// two distinct bit layouts render as separate tables. Two-space prose outside a
// write header is register-level commentary; six-space lines continue the
// preceding bit definition.
import { Register, RegisterAccess, RegisterDetail, isIssueRestricted } from "@/utils/register_parser";
export const parseDescription44 = (reg: Register, description: string) => {
const descriptionLines = description.split('\n');
reg.modes = reg.modes || [];
let currentDetail: RegisterDetail | null = null;
let currentAccess: RegisterAccess | null = null;
const finishDetail = () => {
if (currentDetail && currentAccess) {
currentDetail.common = currentAccess;
reg.modes.push(currentDetail);
}
currentDetail = null;
currentAccess = null;
};
for (const line of descriptionLines) {
reg.source.push(line);
reg.search += line.toLowerCase() + " ";
const trimmedLine = line.trim();
if (!trimmedLine) continue;
if (trimmedLine.startsWith('//')) continue;
if (isIssueRestricted(line)) reg.issue_4_only = true;
const spaces_at_start = line.match(/^(\s*)/)?.[0].length || 0;
// The lone "(R/W)" marker just confirms the access type; both writes are R/W.
if (trimmedLine.startsWith('(R/W')) continue;
// A write header: "1st write:", "2nd write:", etc. starts a new mode.
if (spaces_at_start <= 2 && /^\d+(st|nd|rd|th)\s+write:?$/i.test(trimmedLine)) {
finishDetail();
currentDetail = { read: undefined, write: undefined, common: undefined, text: '' };
currentDetail.modeName = trimmedLine.replace(/:$/, '');
currentAccess = { operations: [], notes: [] };
continue;
}
const bitMatch = trimmedLine.match(/^(bits?|bit)\s+([\d:-]+)\s*=\s*(.*)/);
// Bit definitions live under a write header at deeper indentation.
if (currentAccess && spaces_at_start >= 4 && bitMatch) {
currentAccess.operations.push({
bits: bitMatch[2],
description: bitMatch[3].trim(),
});
continue;
}
// Six-space (or deeper) prose continues the previous bit definition.
if (currentAccess && spaces_at_start >= 6 && currentAccess.operations.length > 0) {
const ops = currentAccess.operations;
ops[ops.length - 1].description += `\n${trimmedLine}`;
continue;
}
// Four-space prose inside a write block is access-level description.
if (currentAccess && spaces_at_start >= 4) {
currentAccess.description = currentAccess.description
? `${currentAccess.description}\n${trimmedLine}`
: trimmedLine;
continue;
}
// Anything shallower (two-space prose outside a write block) is register commentary.
reg.text += `${trimmedLine}\n`;
}
finishDetail();
};

View File

@@ -1,5 +1,9 @@
import {Register, RegisterAccess, RegisterDetail, Note} from "@/utils/register_parser"; import {Register, RegisterAccess, RegisterDetail, Note, detailHasContent, isIssueRestricted} from "@/utils/register_parser";
// Default parser for the common register format: optional (R) / (W) / (R/W)
// access blocks, each holding "bit(s) N = description" rows, with footnotes
// (*, **, ...) that may span multiple indented lines, plus free prose. Produces
// a single RegisterDetail (mode) per register.
export const parseDescriptionDefault = (reg: Register, description: string) => { export const parseDescriptionDefault = (reg: Register, description: string) => {
const descriptionLines = description.split('\n'); const descriptionLines = description.split('\n');
let currentAccess: 'read' | 'write' | 'common' | null = null; let currentAccess: 'read' | 'write' | 'common' | null = null;
@@ -10,13 +14,11 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
// Footnote multiline state // Footnote multiline state
let inFootnote = false; let inFootnote = false;
let footnoteBaseIndent = 0; let footnoteBaseIndent = 0;
// let footnoteTarget: 'global' | 'access' | null = null;
let currentFootnote: Note | null = null; let currentFootnote: Note | null = null;
const endFootnoteIfActive = () => { const endFootnoteIfActive = () => {
inFootnote = false; inFootnote = false;
footnoteBaseIndent = 0; footnoteBaseIndent = 0;
// footnoteTarget = null;
currentFootnote = null; currentFootnote = null;
}; };
@@ -30,7 +32,7 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
reg.search += line.toLowerCase() + " "; reg.search += line.toLowerCase() + " ";
const spaces_at_start = line.match(/^(\s*)/)?.[0].length || 0; const spaces_at_start = line.match(/^(\s*)/)?.[0].length || 0;
if (line.includes('Issue 4 Only')) reg.issue_4_only = true; if (isIssueRestricted(line)) reg.issue_4_only = true;
// Handle multiline footnote continuation // Handle multiline footnote continuation
if (inFootnote) { if (inFootnote) {
@@ -73,7 +75,8 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
currentAccess = 'common'; currentAccess = 'common';
continue; continue;
} }
// New top-level text block (no leading spaces) // A line with no leading whitespace (line === its own trimmed form)
// starts a new top-level text block, so close any open access block.
if (line.startsWith(trimmedLine)) { if (line.startsWith(trimmedLine)) {
if (currentAccess) { if (currentAccess) {
detail[currentAccess] = accessData; detail[currentAccess] = accessData;
@@ -89,10 +92,8 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
const note: Note = { ref: noteMatch[1], text: noteMatch[2] }; const note: Note = { ref: noteMatch[1], text: noteMatch[2] };
if (currentAccess) { if (currentAccess) {
accessData.notes.push(note); accessData.notes.push(note);
// footnoteTarget = 'access';
} else { } else {
reg.notes.push(note); reg.notes.push(note);
// footnoteTarget = 'global';
} }
currentFootnote = note; currentFootnote = note;
inFootnote = true; inFootnote = true;
@@ -103,7 +104,6 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
if (currentAccess) { if (currentAccess) {
const bitMatch = trimmedLine.match(/^(bits?|bit)\s+([\d:-]+)\s*=\s*(.*)/); const bitMatch = trimmedLine.match(/^(bits?|bit)\s+([\d:-]+)\s*=\s*(.*)/);
// const valueMatch = !line.match(/^\s+/) && trimmedLine.match(/^([01\s]+)\s*=\s*(.*)/);
if (bitMatch) { if (bitMatch) {
let bitDescription = bitMatch[3]; let bitDescription = bitMatch[3];
@@ -118,13 +118,9 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
description: bitDescription, description: bitDescription,
footnoteRef: footnoteRef, footnoteRef: footnoteRef,
}); });
// } else if (valueMatch) {
// console.error("VALUE MATCH",valueMatch);
// accessData.operations.push({
// bits: valueMatch[1].trim().replace(/\s/g, ''),
// description: valueMatch[2].trim(),
// });
} else if (trimmedLine) { } else if (trimmedLine) {
// Prose indented exactly two spaces inside an access block is
// register-level commentary rather than part of the bit table.
if(spaces_at_start == 2) { if(spaces_at_start == 2) {
reg.text += `${line}\n`; reg.text += `${line}\n`;
continue; continue;
@@ -151,7 +147,10 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
if (currentAccess) { if (currentAccess) {
detail[currentAccess] = accessData; detail[currentAccess] = accessData;
} }
// Push the parsed detail into modes // Push the parsed detail into modes, unless it is empty — body-less
// registers (e.g. the "Reserved" entries) would otherwise add a blank mode.
reg.modes = reg.modes || []; reg.modes = reg.modes || [];
reg.modes.push(detail); if (detailHasContent(detail)) {
reg.modes.push(detail);
}
}; };

View File

@@ -5,7 +5,7 @@
// - Lines with three or more leading spaces (>=3) belong to the current mode. // - Lines with three or more leading spaces (>=3) belong to the current mode.
// - A line with exactly two spaces followed by '*' is a parent (register-level) note, not a mode note. // - A line with exactly two spaces followed by '*' is a parent (register-level) note, not a mode note.
// - Inside access blocks for F0, lines starting with '*' are headings for description (not notes). // - Inside access blocks for F0, lines starting with '*' are headings for description (not notes).
import { Register, RegisterAccess, RegisterDetail } from "@/utils/register_parser"; import { Register, RegisterAccess, RegisterDetail, detailHasContent, isIssueRestricted } from "@/utils/register_parser";
export const parseDescriptionF0 = (reg: Register, description: string) => { export const parseDescriptionF0 = (reg: Register, description: string) => {
const descriptionLines = description.split('\n'); const descriptionLines = description.split('\n');
@@ -37,7 +37,10 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
// finalize previous access block into detail // finalize previous access block into detail
detail[currentAccess] = accessData; detail[currentAccess] = accessData;
} }
reg.modes.push(detail); // Skip the initial blank detail that precedes the first mode header.
if (detailHasContent(detail)) {
reg.modes.push(detail);
}
detail = {read: undefined, write: undefined, common: undefined, text: ''}; detail = {read: undefined, write: undefined, common: undefined, text: ''};
detail.modeName = trimmedLine; detail.modeName = trimmedLine;
@@ -47,7 +50,7 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
} }
} }
if (line.includes('Issue 4 Only')) reg.issue_4_only = true; if (isIssueRestricted(line)) reg.issue_4_only = true;
if (trimmedLine.startsWith('//')) continue; if (trimmedLine.startsWith('//')) continue;
@@ -110,20 +113,17 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
} }
} }
} else if (trimmedLine) { } else if (trimmedLine) {
if (line.match(/^\s+/) && accessData.operations.length > 0) { // Prose outside any access block (the leading "R/W Issues 4 and 5 Only -
accessData.operations[accessData.operations.length - 1].description += `\n${line}`; // (soft reset = 0x80)" line) is register-level commentary.
} else { reg.text += `${trimmedLine}\n`;
if (!accessData.description) {
accessData.description = '';
}
accessData.description += `\n${trimmedLine}`;
}
} }
} }
if (currentAccess) { if (currentAccess) {
detail[currentAccess] = accessData; detail[currentAccess] = accessData;
} }
// Push the parsed detail into modes // Push the final mode's detail into modes (if it carries content).
reg.modes.push(detail); if (detailHasContent(detail)) {
reg.modes.push(detail);
}
}; };

8
src/utils/serialize.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Deep-clone a value through JSON round-trip.
* Strips non-serializable properties (e.g. Drizzle decimal wrappers)
* so the result is safe to pass from Server Components to Client Components.
*/
export function serialize<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}