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:
@@ -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
286
docs/todo.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# 📋 Next Explorer — Code Review & TODO
|
||||||
|
|
||||||
|
> Full codebase review performed 2026-03-04. Findings grouped by priority and area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Critical
|
||||||
|
|
||||||
|
### 🐛 Bug: `FileViewer` uses `useState` as `useEffect`
|
||||||
|
|
||||||
|
**File:** `src/components/FileViewer.tsx:23`
|
||||||
|
|
||||||
|
`useState(() => { ... })` is being abused as a side-effect initializer — it calls `fetch()` inside `useState`'s initializer function. This works by accident on first render but violates React rules:
|
||||||
|
- The initializer can run multiple times in Strict Mode (double-invocation).
|
||||||
|
- It never re-runs if `url` or `title` props change.
|
||||||
|
- Side effects in `useState` initializers are explicitly discouraged by React.
|
||||||
|
|
||||||
|
**Fix:** Replace with `useEffect` with proper dependency array on `[url, isText]`.
|
||||||
|
|
||||||
|
### 🐛 Bug: Register service caching is commented out
|
||||||
|
|
||||||
|
**File:** `src/services/register.service.ts:14-18`
|
||||||
|
|
||||||
|
The `if (registers.length === 0)` guard is commented out, so the file is re-read and re-parsed on every call to `getRegisters()`. This means every register page load (including the OG image generator and `generateMetadata`) re-parses the entire `nextreg.txt` file. The `[hex]/page.tsx` calls `getRegisters()` twice (once in `generateMetadata`, once in the page function), reading the file from disk twice per request.
|
||||||
|
|
||||||
|
**Fix:** Uncomment the caching guard, or better yet, wrap with React `cache()` for request-level deduplication.
|
||||||
|
|
||||||
|
### 🔒 Security: `middleware.js` is untyped JavaScript
|
||||||
|
|
||||||
|
**File:** `src/middleware.js`
|
||||||
|
|
||||||
|
The only `.js` file in the project. It logs every request path to stdout, including potentially sensitive paths. In production this creates noise and potential log injection vectors.
|
||||||
|
|
||||||
|
**Fix:** Convert to TypeScript (`.ts`). Consider restricting logging to development only, or removing it entirely since Next.js has built-in request logging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 High Priority
|
||||||
|
|
||||||
|
### 🧱 Architecture: Duplicated type definitions across files
|
||||||
|
|
||||||
|
`Paged<T>`, `Item`, `SearchScope`, `EntryFacets`, and similar types are independently re-declared in:
|
||||||
|
- `src/hooks/useSearchFetch.ts`
|
||||||
|
- `src/app/zxdb/entries/EntriesExplorer.tsx`
|
||||||
|
- `src/app/zxdb/releases/ReleasesExplorer.tsx`
|
||||||
|
- `src/app/zxdb/labels/LabelsSearch.tsx`
|
||||||
|
- `src/app/zxdb/genres/GenresSearch.tsx`
|
||||||
|
|
||||||
|
And the `EntryDetailData` type in `EntryDetail.tsx` is a near-duplicate of `EntryDetail` in `src/server/repo/zxdb.ts`.
|
||||||
|
|
||||||
|
**Fix:** Extract shared types to `src/types/zxdb.ts` and import everywhere.
|
||||||
|
|
||||||
|
### 🧱 Architecture: Duplicated `parseMachineIds` / `parseIdList` helpers
|
||||||
|
|
||||||
|
The same ID-parsing logic appears in:
|
||||||
|
- `src/app/zxdb/entries/page.tsx`
|
||||||
|
- `src/app/zxdb/entries/EntriesExplorer.tsx`
|
||||||
|
- `src/app/zxdb/releases/page.tsx`
|
||||||
|
- `src/app/zxdb/releases/ReleasesExplorer.tsx`
|
||||||
|
- `src/app/api/zxdb/search/route.ts`
|
||||||
|
|
||||||
|
**Fix:** Extract to a shared utility (e.g., `src/utils/params.ts`).
|
||||||
|
|
||||||
|
### 🧱 Architecture: Duplicated `buildRegisterSummary` logic
|
||||||
|
|
||||||
|
`[hex]/page.tsx` and `[hex]/opengraph-image.tsx` each have their own version of register-summary-building logic (one returns a string, one returns lines). The `isInfoLine` filter is duplicated.
|
||||||
|
|
||||||
|
**Fix:** Extract to a shared utility in `src/utils/register_helpers.ts`.
|
||||||
|
|
||||||
|
### ⚡ Performance: Repository file is 800+ lines with no code splitting
|
||||||
|
|
||||||
|
**File:** `src/server/repo/zxdb.ts` (31,000+ tokens)
|
||||||
|
|
||||||
|
This monolithic file contains all DB queries. It's hard to navigate and cannot benefit from tree-shaking at the module level.
|
||||||
|
|
||||||
|
**Fix:** Split into per-domain files: `repo/entries.ts`, `repo/labels.ts`, `repo/releases.ts`, `repo/magazines.ts`, `repo/lookups.ts`, etc.
|
||||||
|
|
||||||
|
### ⚡ Performance: `releases/page.tsx` uses `JSON.parse(JSON.stringify(...))` for serialization
|
||||||
|
|
||||||
|
**File:** `src/app/zxdb/releases/page.tsx:51-63`
|
||||||
|
|
||||||
|
Uses `JSON.parse(JSON.stringify(initial))` to strip non-serializable values. This is a known workaround for Drizzle decimal types. However, it's applied 7 times in the same function.
|
||||||
|
|
||||||
|
**Fix:** Create a `serialize()` helper, or configure Drizzle's `decimal` columns to return strings/numbers natively.
|
||||||
|
|
||||||
|
### 🎨 UI Consistency: Mixed raw HTML and react-bootstrap components
|
||||||
|
|
||||||
|
Per `CLAUDE.md`, the project should always use react-bootstrap components. Several pages use raw HTML instead:
|
||||||
|
|
||||||
|
| File | Issue |
|
||||||
|
|------|-------|
|
||||||
|
| `LabelsSearch.tsx` | Raw `<table>`, `<input>`, `<button>`, `<form>` instead of `Table`, `Form.*`, `Button` |
|
||||||
|
| `LabelDetail.tsx` | Raw `<table>`, `<input>`, `<button>`, nav tabs using raw `<ul>/<li>/<button>` |
|
||||||
|
| `GenresSearch.tsx` | Raw `<table>`, `<input>`, `<button>` |
|
||||||
|
| `MachineTypesSearch.tsx` | Likely same pattern |
|
||||||
|
| `LanguagesSearch.tsx` | Likely same pattern |
|
||||||
|
| `ReleaseDetail.tsx` | Raw `<table>` throughout instead of `<Table>` |
|
||||||
|
| `zxdb/page.tsx` | Raw `<input>`, `<select>`, `<button>` in the search form |
|
||||||
|
| `magazines/page.tsx` | Raw `<table>`, local `Pagination` component instead of shared one |
|
||||||
|
| `magazines/[id]/page.tsx` | Raw `<table>` |
|
||||||
|
| `issues/[id]/page.tsx` | Raw `<table>` |
|
||||||
|
| `page.tsx` (home) | Raw `<div className="card">` instead of `<Card>` |
|
||||||
|
| `TapeIdentifier.tsx` | Raw `<table>` for hash display |
|
||||||
|
|
||||||
|
**Fix:** Systematically replace with react-bootstrap equivalents to match `EntriesExplorer.tsx` and `RegisterBrowser.tsx` patterns.
|
||||||
|
|
||||||
|
### 🎨 UI: Magazines page has its own inline `Pagination` component
|
||||||
|
|
||||||
|
**File:** `src/app/zxdb/magazines/page.tsx:89-117`
|
||||||
|
|
||||||
|
Defines a local `Pagination` function instead of using the shared `src/components/explorer/Pagination.tsx`.
|
||||||
|
|
||||||
|
**Fix:** Use the shared `Pagination` component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Medium Priority
|
||||||
|
|
||||||
|
### ⚛️ React: `useSearchFetch` `onExtra` callback may cause infinite loops
|
||||||
|
|
||||||
|
**File:** `src/hooks/useSearchFetch.ts:75`
|
||||||
|
|
||||||
|
The `fetch_` callback depends on `[endpoint, onExtra]`. If the caller doesn't memoize `onExtra`, this dependency changes every render, creating a new `fetch_` reference, which could cascade into effect re-runs.
|
||||||
|
|
||||||
|
The `EntriesExplorer.tsx` correctly `useCallback`-wraps `handleExtra`, but this is a fragile contract.
|
||||||
|
|
||||||
|
**Fix:** Store `onExtra` in a ref instead of including it in the dependency array, or document the requirement clearly.
|
||||||
|
|
||||||
|
### ⚛️ React: Missing `notFound()` call in entry detail page
|
||||||
|
|
||||||
|
**File:** `src/app/zxdb/entries/[id]/page.tsx:13-15`
|
||||||
|
|
||||||
|
When `getEntryById` returns null, the page still renders with status 200 — the client component shows an "alert-warning" div. This means:
|
||||||
|
- Search engines index a 200 page with "Not found" content.
|
||||||
|
- No proper 404 HTTP status.
|
||||||
|
|
||||||
|
**Fix:** Call `notFound()` in the server component when `data` is null, like `magazines/[id]/page.tsx` does.
|
||||||
|
|
||||||
|
### ⚛️ React: Same issue for release detail page
|
||||||
|
|
||||||
|
**File:** `src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx`
|
||||||
|
|
||||||
|
Same pattern — no `notFound()` when `data` is null.
|
||||||
|
|
||||||
|
### ⚛️ React: `RegisterBrowser` disables exhaustive-deps without justification
|
||||||
|
|
||||||
|
**File:** `src/app/registers/RegisterBrowser.tsx:90-91`
|
||||||
|
|
||||||
|
The `eslint-disable-next-line react-hooks/exhaustive-deps` on the `searchParams` sync effect excludes `searchTerm` from deps, which could lead to stale closures if `searchParams` changes while `searchTerm` is mid-update.
|
||||||
|
|
||||||
|
### 📦 Metadata: Entry/release/label detail pages lack dynamic metadata
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/app/zxdb/entries/[id]/page.tsx` — static `metadata = { title: "ZXDB Entry" }`
|
||||||
|
- `src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx` — static `metadata = { title: "ZXDB Release" }`
|
||||||
|
- `src/app/zxdb/labels/[id]/page.tsx` — static `metadata = { title: "ZXDB Label" }`
|
||||||
|
|
||||||
|
These should use `generateMetadata` to include the entry/release/label title for SEO and social sharing, similar to how `registers/[hex]/page.tsx` does it.
|
||||||
|
|
||||||
|
### 🧱 Architecture: `ThemeDropdown` hardcodes cookie domain
|
||||||
|
|
||||||
|
**File:** `src/components/ThemeDropdown.tsx:16`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
document.cookie = `${name}=...; Domain=specnext.dev`;
|
||||||
|
```
|
||||||
|
|
||||||
|
This hardcoded domain means the theme cookie won't work on `localhost` or any non-specnext.dev domain during development.
|
||||||
|
|
||||||
|
**Fix:** Remove the `Domain=` attribute (let it default to current host), or conditionally set it based on environment.
|
||||||
|
|
||||||
|
### ⚡ Performance: No `loading.tsx` or `Suspense` boundaries
|
||||||
|
|
||||||
|
None of the route segments define `loading.tsx` files. For `force-dynamic` pages (entries, releases, labels, genres, languages, machinetypes), users see a blank page or frozen UI while the server fetches from MySQL.
|
||||||
|
|
||||||
|
**Fix:** Add `loading.tsx` with skeleton/spinner states for ZXDB routes.
|
||||||
|
|
||||||
|
### ⚡ Performance: `opengraph-image.tsx` calls `getRegisters()` for every OG image
|
||||||
|
|
||||||
|
**File:** `src/app/registers/[hex]/opengraph-image.tsx:132-136`
|
||||||
|
|
||||||
|
Loads ALL registers from disk just to find one. With the caching fix above this becomes a non-issue, but currently it's reading and parsing the full file for each image request.
|
||||||
|
|
||||||
|
### 🔒 Security: Download API path traversal protection should normalize before joining
|
||||||
|
|
||||||
|
**File:** `src/app/api/zxdb/download/route.ts:27-28`
|
||||||
|
|
||||||
|
The current protection `path.normalize(path.join(baseDir, filePath))` is correct, but the check should also reject paths containing `..` before `join` for defense-in-depth.
|
||||||
|
|
||||||
|
### 📝 DX: `parseNextReg()` is async but does no async work
|
||||||
|
|
||||||
|
**File:** `src/utils/register_parser.ts:49`
|
||||||
|
|
||||||
|
`parseNextReg()` is declared `async` and returns `Promise<Register[]>`, but the function body is entirely synchronous. This forces callers to `await` unnecessarily.
|
||||||
|
|
||||||
|
**Fix:** Remove `async` and return `Register[]` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Low Priority
|
||||||
|
|
||||||
|
### 🎨 UI: `Navbar` uses `Link` with `className="nav-link"` instead of `Nav.Link`
|
||||||
|
|
||||||
|
**File:** `src/components/Navbar.tsx:14-16`
|
||||||
|
|
||||||
|
For consistency with react-bootstrap patterns, use `<Nav.Link as={Link} href="...">` instead of raw `<Link className="nav-link">`.
|
||||||
|
|
||||||
|
### 🎨 UI: Home page uses `bi bi-*` CSS classes instead of react-bootstrap-icons
|
||||||
|
|
||||||
|
**File:** `src/app/page.tsx:12,29`
|
||||||
|
|
||||||
|
Uses `<span className="bi bi-collection">` instead of the `react-bootstrap-icons` package that's used elsewhere.
|
||||||
|
|
||||||
|
### 🎨 UI: `TapeIdentifier` uses `bi bi-*` CSS classes
|
||||||
|
|
||||||
|
**File:** `src/app/zxdb/TapeIdentifier.tsx`
|
||||||
|
|
||||||
|
Same issue — uses Bootstrap icon CSS classes instead of `react-bootstrap-icons` components.
|
||||||
|
|
||||||
|
### 🎨 UI: Inconsistent "not found" patterns
|
||||||
|
|
||||||
|
Some pages use `notFound()` (magazines, issues), others render inline alerts (entries, releases, labels). This creates inconsistent UX.
|
||||||
|
|
||||||
|
### ⚛️ React: `buildRegisterSummaryLines` in OG image could use better key strategy
|
||||||
|
|
||||||
|
**File:** `src/app/registers/[hex]/opengraph-image.tsx:174,188`
|
||||||
|
|
||||||
|
Uses `key={line}` which will produce duplicate keys if two lines have identical text.
|
||||||
|
|
||||||
|
### 📝 DX: Commented-out code in register parsers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/utils/register_parsers/reg_default.ts:13,106,122-126` — commented `footnoteTarget` variable and `valueMatch` block
|
||||||
|
- `src/services/register.service.ts:14,18` — commented caching guard
|
||||||
|
|
||||||
|
**Fix:** Remove dead code or convert to tracked TODOs.
|
||||||
|
|
||||||
|
### 📝 DX: `app/page.module.css` exists but is never imported
|
||||||
|
|
||||||
|
Check if this file has any content; if empty/unused, remove it.
|
||||||
|
|
||||||
|
### 🧪 Testing: Zero test coverage
|
||||||
|
|
||||||
|
No test files exist (`*.test.ts`, `*.spec.ts`, `__tests__/`). Key areas that would benefit:
|
||||||
|
1. Register parser (`parseNextReg`, `parseDescriptionDefault`, `parseDescriptionF0`) — complex parsing logic with edge cases.
|
||||||
|
2. API route input validation (Zod schemas).
|
||||||
|
3. `useSearchFetch` hook behavior (cancellation, race conditions).
|
||||||
|
4. `computeMd5` correctness.
|
||||||
|
5. Component rendering for entry detail, release detail.
|
||||||
|
|
||||||
|
### ♿ Accessibility: Search inputs lack proper labeling
|
||||||
|
|
||||||
|
Several search inputs use `placeholder` as the only label (no associated `<label>` or `aria-label`):
|
||||||
|
- `zxdb/page.tsx` search form
|
||||||
|
- Various filter sidebars
|
||||||
|
|
||||||
|
### ♿ Accessibility: Tables lack `<caption>` elements
|
||||||
|
|
||||||
|
Data tables throughout the ZXDB explorer have no `<caption>`, making it harder for screen readers to understand table purpose.
|
||||||
|
|
||||||
|
### ⚡ Performance: `EntriesExplorer` and `ReleasesExplorer` have very similar structures
|
||||||
|
|
||||||
|
Both follow the same pattern: sidebar with filters, table results, pagination. They share about 60% structural similarity. Consider extracting a shared `SearchExplorer` wrapper that accepts column definitions and filter config.
|
||||||
|
|
||||||
|
### 📦 Bundle: `import * as Icon from 'react-bootstrap-icons'`
|
||||||
|
|
||||||
|
**File:** `src/app/registers/RegisterDetail.tsx:8`, `src/components/ThemeDropdown.tsx:4`
|
||||||
|
|
||||||
|
Importing the entire icon library. While tree-shaking should handle this, named imports are safer and make dependencies explicit.
|
||||||
|
|
||||||
|
**Fix:** `import { Wikipedia, Link45deg, CodeSlash } from 'react-bootstrap-icons'`
|
||||||
|
|
||||||
|
### 📝 DX: `CLAUDE.md` structure tree is outdated
|
||||||
|
|
||||||
|
The project tree in `CLAUDE.md` doesn't reflect the current file structure (missing `hooks/`, `components/explorer/`, ZXDB pages, API routes, `server/`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Feature Ideas (non-bugs)
|
||||||
|
|
||||||
|
- **Search debouncing** — `RegisterBrowser` updates the URL on every keystroke. Consider debouncing the URL update (keep instant local filtering).
|
||||||
|
- **Entry detail OG images** — Register pages have OG images; ZXDB entry pages do not.
|
||||||
|
- **Keyboard navigation** — Add keyboard shortcuts for pagination (left/right arrows).
|
||||||
|
- **Back-to-top button** — Long entry detail pages would benefit.
|
||||||
|
- **Error boundaries** — No `error.tsx` files exist for graceful error recovery in route segments.
|
||||||
|
- **Rate limiting** — API routes have no rate limiting for the search endpoints.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listAvailabletypes } from "@/server/repo/zxdb";
|
import { listAvailabletypes } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await listAvailabletypes();
|
const items = await listAvailabletypes();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listCasetypes } from "@/server/repo/zxdb";
|
import { listCasetypes } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await listCasetypes();
|
const items = await listCasetypes();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listCurrencies } from "@/server/repo/zxdb";
|
import { listCurrencies } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await listCurrencies();
|
const items = await listCurrencies();
|
||||||
|
|||||||
@@ -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))) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getEntryById } from "@/server/repo/zxdb";
|
import { getEntryById } from "@/server/repo";
|
||||||
|
|
||||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listFiletypes } from "@/server/repo/zxdb";
|
import { listFiletypes } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await listFiletypes();
|
const items = await listFiletypes();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { entriesByGenre } from "@/server/repo/zxdb";
|
import { entriesByGenre } from "@/server/repo";
|
||||||
|
|
||||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listGenres } from "@/server/repo/zxdb";
|
import { listGenres } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const data = await listGenres();
|
const data = await listGenres();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
|
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo";
|
||||||
|
|
||||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { searchLabels } from "@/server/repo/zxdb";
|
import { searchLabels } from "@/server/repo";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { entriesByLanguage } from "@/server/repo/zxdb";
|
import { entriesByLanguage } from "@/server/repo";
|
||||||
|
|
||||||
const paramsSchema = z.object({ id: z.string().trim().length(2) });
|
const paramsSchema = z.object({ id: z.string().trim().length(2) });
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listLanguages } from "@/server/repo/zxdb";
|
import { listLanguages } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const data = await listLanguages();
|
const data = await listLanguages();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { entriesByMachinetype } from "@/server/repo/zxdb";
|
import { entriesByMachinetype } from "@/server/repo";
|
||||||
|
|
||||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listMachinetypes } from "@/server/repo/zxdb";
|
import { listMachinetypes } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const data = await listMachinetypes();
|
const data = await listMachinetypes();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { searchReleases } from "@/server/repo/zxdb";
|
import { searchReleases } from "@/server/repo";
|
||||||
|
import { parseIdList } from "@/utils/params";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
@@ -17,15 +18,6 @@ const querySchema = z.object({
|
|||||||
isDemo: z.coerce.boolean().optional(),
|
isDemo: z.coerce.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseIdList(value: string | undefined) {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const ids = value
|
|
||||||
.split(",")
|
|
||||||
.map((id) => Number(id.trim()))
|
|
||||||
.filter((id) => Number.isFinite(id) && id > 0);
|
|
||||||
return ids.length ? ids : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const parsed = querySchema.safeParse({
|
const parsed = querySchema.safeParse({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listRoletypes } from "@/server/repo/zxdb";
|
import { listRoletypes } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await listRoletypes();
|
const items = await listRoletypes();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listSchemetypes } from "@/server/repo/zxdb";
|
import { listSchemetypes } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await listSchemetypes();
|
const items = await listSchemetypes();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { searchEntries, getEntryFacets } from "@/server/repo/zxdb";
|
import { searchEntries, getEntryFacets } from "@/server/repo";
|
||||||
|
import { parseIdList } from "@/utils/params";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listSourcetypes } from "@/server/repo/zxdb";
|
import { listSourcetypes } from "@/server/repo";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await listSourcetypes();
|
const items = await listSourcetypes();
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
/*.page {*/
|
|
||||||
/* --gray-rgb: 0, 0, 0;*/
|
|
||||||
/* --gray-alpha-200: rgba(var(--gray-rgb), 0.08);*/
|
|
||||||
/* --gray-alpha-100: rgba(var(--gray-rgb), 0.05);*/
|
|
||||||
|
|
||||||
/* --button-primary-hover: #383838;*/
|
|
||||||
/* --button-secondary-hover: #f2f2f2;*/
|
|
||||||
|
|
||||||
/* display: grid;*/
|
|
||||||
/* grid-template-rows: 20px 1fr 20px;*/
|
|
||||||
/* align-items: center;*/
|
|
||||||
/* justify-items: center;*/
|
|
||||||
/* min-height: 100svh;*/
|
|
||||||
/* padding: 80px;*/
|
|
||||||
/* gap: 64px;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*@media (prefers-color-scheme: dark) {*/
|
|
||||||
/* .page {*/
|
|
||||||
/* --gray-rgb: 255, 255, 255;*/
|
|
||||||
/* --gray-alpha-200: rgba(var(--gray-rgb), 0.145);*/
|
|
||||||
/* --gray-alpha-100: rgba(var(--gray-rgb), 0.06);*/
|
|
||||||
|
|
||||||
/* --button-primary-hover: #ccc;*/
|
|
||||||
/* --button-secondary-hover: #1a1a1a;*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.main {*/
|
|
||||||
/* display: flex;*/
|
|
||||||
/* flex-direction: column;*/
|
|
||||||
/* gap: 32px;*/
|
|
||||||
/* grid-row-start: 2;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.main ol {*/
|
|
||||||
/* padding-left: 0;*/
|
|
||||||
/* margin: 0;*/
|
|
||||||
/* font-size: 14px;*/
|
|
||||||
/* line-height: 24px;*/
|
|
||||||
/* letter-spacing: -0.01em;*/
|
|
||||||
/* list-style-position: inside;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.main li:not(:last-of-type) {*/
|
|
||||||
/* margin-bottom: 8px;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.main code {*/
|
|
||||||
/* font-family: inherit;*/
|
|
||||||
/* background: var(--gray-alpha-100);*/
|
|
||||||
/* padding: 2px 4px;*/
|
|
||||||
/* border-radius: 4px;*/
|
|
||||||
/* font-weight: 600;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.ctas {*/
|
|
||||||
/* display: flex;*/
|
|
||||||
/* gap: 16px;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.ctas a {*/
|
|
||||||
/* appearance: none;*/
|
|
||||||
/* border-radius: 128px;*/
|
|
||||||
/* height: 48px;*/
|
|
||||||
/* padding: 0 20px;*/
|
|
||||||
/* border: 1px solid transparent;*/
|
|
||||||
/* transition:*/
|
|
||||||
/* background 0.2s,*/
|
|
||||||
/* color 0.2s,*/
|
|
||||||
/* border-color 0.2s;*/
|
|
||||||
/* cursor: pointer;*/
|
|
||||||
/* display: flex;*/
|
|
||||||
/* align-items: center;*/
|
|
||||||
/* justify-content: center;*/
|
|
||||||
/* font-size: 16px;*/
|
|
||||||
/* line-height: 20px;*/
|
|
||||||
/* font-weight: 500;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*a.primary {*/
|
|
||||||
/* gap: 8px;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*a.secondary {*/
|
|
||||||
/* border-color: var(--gray-alpha-200);*/
|
|
||||||
/* min-width: 158px;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.footer {*/
|
|
||||||
/* grid-row-start: 3;*/
|
|
||||||
/* display: flex;*/
|
|
||||||
/* gap: 24px;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.footer a {*/
|
|
||||||
/* display: flex;*/
|
|
||||||
/* align-items: center;*/
|
|
||||||
/* gap: 8px;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*.footer img {*/
|
|
||||||
/* flex-shrink: 0;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*!* Enable hover only on non-touch devices *!*/
|
|
||||||
/*@media (hover: hover) and (pointer: fine) {*/
|
|
||||||
/* a.primary:hover {*/
|
|
||||||
/* background: var(--button-primary-hover);*/
|
|
||||||
/* border-color: transparent;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* a.secondary:hover {*/
|
|
||||||
/* background: var(--button-secondary-hover);*/
|
|
||||||
/* border-color: transparent;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* .footer a:hover {*/
|
|
||||||
/* text-decoration: underline;*/
|
|
||||||
/* text-underline-offset: 4px;*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*@media (max-width: 600px) {*/
|
|
||||||
/* .page {*/
|
|
||||||
/* padding: 32px;*/
|
|
||||||
/* padding-bottom: 80px;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* .main {*/
|
|
||||||
/* align-items: center;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* .main ol {*/
|
|
||||||
/* text-align: center;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* .ctas {*/
|
|
||||||
/* flex-direction: column;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* .ctas a {*/
|
|
||||||
/* font-size: 14px;*/
|
|
||||||
/* height: 40px;*/
|
|
||||||
/* padding: 0 16px;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* a.secondary {*/
|
|
||||||
/* min-width: auto;*/
|
|
||||||
/* }*/
|
|
||||||
|
|
||||||
/* .footer {*/
|
|
||||||
/* flex-wrap: wrap;*/
|
|
||||||
/* align-items: center;*/
|
|
||||||
/* justify-content: center;*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*@media (prefers-color-scheme: dark) {*/
|
|
||||||
/* .logo {*/
|
|
||||||
/* filter: invert();*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Collection, Cpu } from "react-bootstrap-icons";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
@@ -8,7 +9,7 @@ export default function Home() {
|
|||||||
<div className="card h-100 shadow-sm">
|
<div className="card h-100 shadow-sm">
|
||||||
<div className="card-body d-flex flex-column gap-3">
|
<div className="card-body d-flex flex-column gap-3">
|
||||||
<div className="d-flex align-items-center gap-3">
|
<div className="d-flex align-items-center gap-3">
|
||||||
<span className="bi bi-collection" style={{ fontSize: 40 }} aria-hidden />
|
<Collection size={40} aria-hidden />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="h3 mb-1">ZXDB Explorer</h1>
|
<h1 className="h3 mb-1">ZXDB Explorer</h1>
|
||||||
<p className="text-secondary mb-0">Search entries, releases, magazines, and labels.</p>
|
<p className="text-secondary mb-0">Search entries, releases, magazines, and labels.</p>
|
||||||
@@ -26,7 +27,7 @@ export default function Home() {
|
|||||||
<div className="card h-100 shadow-sm">
|
<div className="card h-100 shadow-sm">
|
||||||
<div className="card-body d-flex flex-column gap-3">
|
<div className="card-body d-flex flex-column gap-3">
|
||||||
<div className="d-flex align-items-center gap-3">
|
<div className="d-flex align-items-center gap-3">
|
||||||
<span className="bi bi-cpu" style={{ fontSize: 40 }} aria-hidden />
|
<Cpu size={40} aria-hidden />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="h3 mb-1">NextReg Explorer</h2>
|
<h2 className="h3 mb-1">NextReg Explorer</h2>
|
||||||
<p className="text-secondary mb-0">Browse Spectrum Next registers and bitfields.</p>
|
<p className="text-secondary mb-0">Browse Spectrum Next registers and bitfields.</p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Col, Card, Tabs, Tab, Button, Modal } from 'react-bootstrap';
|
|||||||
import { Register } from '@/utils/register_parser';
|
import { Register } from '@/utils/register_parser';
|
||||||
import { renderAccess } from './RegisterBrowser';
|
import { renderAccess } from './RegisterBrowser';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import * as Icon from 'react-bootstrap-icons';
|
import { Wikipedia, Link45deg, CodeSlash } from 'react-bootstrap-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A client-side component that displays the details of a single register.
|
* A client-side component that displays the details of a single register.
|
||||||
@@ -24,18 +24,18 @@ export default function RegisterDetail({
|
|||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<code>{register.hex_address}</code> ( {register.dec_address} )
|
<code>{register.hex_address}</code> ( {register.dec_address} )
|
||||||
<strong>{register.name}</strong> {register.issue_4_only && <span className="badge bg-danger">Issue 4 Only</span>}
|
<strong>{register.name}</strong> {register.issue_4_only && <span className="badge bg-danger">Issues 4 & 5 Only</span>}
|
||||||
<div className="float-end small text-muted">
|
<div className="float-end small text-muted">
|
||||||
<Link href={register.wiki_link} className="text-decoration-none btn btn-sm btn-primary" title="Open wiki">
|
<Link href={register.wiki_link} className="text-decoration-none btn btn-sm btn-primary" title="Open wiki">
|
||||||
<Icon.Wikipedia />
|
<Wikipedia />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href={`/registers/${register.hex_address}`} className="text-decoration-none btn btn-sm btn-primary" title="Permalink">
|
<Link href={`/registers/${register.hex_address}`} className="text-decoration-none btn btn-sm btn-primary" title="Permalink">
|
||||||
<Icon.Link45deg />
|
<Link45deg />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Button variant="primary" size="sm" className="text-decoration-none" onClick={() => setShowSource(true)} title="View source">
|
<Button variant="primary" size="sm" className="text-decoration-none" onClick={() => setShowSource(true)} title="View source">
|
||||||
<Icon.CodeSlash />
|
<CodeSlash />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getRegisters } from '@/services/register.service';
|
import { getRegisters } from '@/services/register.service';
|
||||||
|
import { buildRegisterSummaryLines } from '@/utils/register_helpers';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -10,70 +11,6 @@ export const size = {
|
|||||||
|
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
const buildRegisterSummaryLines = (register: { description: string; text: string; modes: { text: string }[] }) => {
|
|
||||||
const isInfoLine = (line: string) =>
|
|
||||||
line.length > 0 &&
|
|
||||||
!line.startsWith('//') &&
|
|
||||||
!line.startsWith('(R') &&
|
|
||||||
!line.startsWith('(W') &&
|
|
||||||
!line.startsWith('(R/W') &&
|
|
||||||
!line.startsWith('*') &&
|
|
||||||
!/^bits?\s+\d/i.test(line);
|
|
||||||
|
|
||||||
const normalizeLines = (raw: string) => {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const rawLines = raw.split('\n');
|
|
||||||
for (const rawLine of rawLines) {
|
|
||||||
const trimmed = rawLine.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isInfoLine(trimmed)) {
|
|
||||||
lines.push(trimmed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
const textLines = normalizeLines(register.text);
|
|
||||||
const modeLines = register.modes.flatMap(mode => normalizeLines(mode.text));
|
|
||||||
const descriptionLines = normalizeLines(register.description);
|
|
||||||
|
|
||||||
const combined: string[] = [];
|
|
||||||
const appendBlock = (block: string[]) => {
|
|
||||||
if (block.length === 0) return;
|
|
||||||
if (combined.length > 0 && combined[combined.length - 1] !== '') {
|
|
||||||
combined.push('');
|
|
||||||
}
|
|
||||||
combined.push(...block);
|
|
||||||
};
|
|
||||||
|
|
||||||
appendBlock(textLines);
|
|
||||||
appendBlock(modeLines);
|
|
||||||
appendBlock(descriptionLines);
|
|
||||||
|
|
||||||
const deduped: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const line of combined) {
|
|
||||||
if (!line) {
|
|
||||||
if (deduped.length > 0 && deduped[deduped.length - 1] !== '') {
|
|
||||||
deduped.push('');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (seen.has(line)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(line);
|
|
||||||
deduped.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deduped.length > 0 ? deduped : ['Spectrum Next register details and bit-level behavior.'];
|
|
||||||
};
|
|
||||||
|
|
||||||
const splitLongWord = (word: string, maxLineLength: number) => {
|
const splitLongWord = (word: string, maxLineLength: number) => {
|
||||||
if (word.length <= maxLineLength) return [word];
|
if (word.length <= maxLineLength) return [word];
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
@@ -171,8 +108,8 @@ export default async function Image({ params }: { params: Promise<{ hex: string
|
|||||||
lineHeight: 1.05,
|
lineHeight: 1.05,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{titleLines.map(line => (
|
{titleLines.map((line, idx) => (
|
||||||
<div key={line}>{line}</div>
|
<div key={idx}>{line}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -185,8 +122,8 @@ export default async function Image({ params }: { params: Promise<{ hex: string
|
|||||||
color: '#f7f1ff',
|
color: '#f7f1ff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{summaryLines.map(line => (
|
{summaryLines.map((line, idx) => (
|
||||||
<div key={line}>{line}</div>
|
<div key={idx}>{line}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 'auto', fontSize: '26px', color: '#dacbff' }}>
|
<div style={{ marginTop: 'auto', fontSize: '26px', color: '#dacbff' }}>
|
||||||
|
|||||||
@@ -5,27 +5,7 @@ import RegisterDetail from '@/app/registers/RegisterDetail';
|
|||||||
import {Container, Row} from "react-bootstrap";
|
import {Container, Row} from "react-bootstrap";
|
||||||
import { getRegisters } from '@/services/register.service';
|
import { getRegisters } from '@/services/register.service';
|
||||||
import {env} from "@/env";
|
import {env} from "@/env";
|
||||||
|
import { buildRegisterSummary } from "@/utils/register_helpers";
|
||||||
const buildRegisterSummary = (register: { description: string; text: string; modes: { text: string }[] }) => {
|
|
||||||
const trimLine = (line: string) => line.trim();
|
|
||||||
const isInfoLine = (line: string) =>
|
|
||||||
line.length > 0 &&
|
|
||||||
!line.startsWith('//') &&
|
|
||||||
!line.startsWith('(R') &&
|
|
||||||
!line.startsWith('(W') &&
|
|
||||||
!line.startsWith('(R/W') &&
|
|
||||||
!line.startsWith('*') &&
|
|
||||||
!/^bits?\s+\d/i.test(line);
|
|
||||||
|
|
||||||
const modeLines = register.modes.flatMap(mode => mode.text.split('\n')).map(trimLine).filter(isInfoLine);
|
|
||||||
const textLines = register.text.split('\n').map(trimLine).filter(isInfoLine);
|
|
||||||
const descriptionLines = register.description.split('\n').map(trimLine).filter(isInfoLine);
|
|
||||||
|
|
||||||
const rawSummary = [...textLines, ...modeLines, ...descriptionLines].join(' ').replace(/\s+/g, ' ').trim();
|
|
||||||
if (!rawSummary) return 'Spectrum Next register details and bit-level behavior.';
|
|
||||||
if (rawSummary.length <= 180) return rawSummary;
|
|
||||||
return `${rawSummary.slice(0, 177).trimEnd()}...`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ hex: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ hex: string }> }): Promise<Metadata> {
|
||||||
const registers = await getRegisters();
|
const registers = await getRegisters();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
9
src/app/zxdb/entries/[id]/loading.tsx
Normal file
9
src/app/zxdb/entries/[id]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
import EntryDetailClient from "./EntryDetail";
|
import EntryDetailClient from "./EntryDetail";
|
||||||
import { getEntryById } from "@/server/repo/zxdb";
|
import { getEntryById } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "ZXDB Entry",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getEntryById(Number(id));
|
||||||
|
if (!data) return { title: "Entry Not Found | ZXDB" };
|
||||||
|
return { title: `${data.title} | ZXDB Entry` };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const numericId = Number(id);
|
const data = await getEntryById(Number(id));
|
||||||
const data = await getEntryById(numericId);
|
if (!data) notFound();
|
||||||
// For simplicity, let the client render a Not Found state if null
|
|
||||||
return <EntryDetailClient data={data} />;
|
return <EntryDetailClient data={data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import EntriesExplorer from "./EntriesExplorer";
|
import EntriesExplorer from "./EntriesExplorer";
|
||||||
import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb";
|
import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo";
|
||||||
|
import { parseIdList } from "@/utils/params";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "ZXDB Entries",
|
title: "ZXDB Entries",
|
||||||
@@ -7,16 +8,6 @@ export const metadata = {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
function parseIdList(value: string | string[] | undefined) {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const raw = Array.isArray(value) ? value.join(",") : value;
|
|
||||||
const ids = raw
|
|
||||||
.split(",")
|
|
||||||
.map((id) => Number(id.trim()))
|
|
||||||
.filter((id) => Number.isFinite(id) && id > 0);
|
|
||||||
return ids.length ? ids : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
|||||||
@@ -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,28 +56,28 @@ 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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</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>
|
||||||
@@ -85,18 +87,17 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((g) => (
|
{data.items.map((g) => (
|
||||||
<tr key={g.id}>
|
<tr key={g.id}>
|
||||||
<td><span className="badge text-bg-light">#{g.id}</span></td>
|
<td><Badge bg="light" text="dark">#{g.id}</Badge></td>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
|
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
</div>
|
</Row>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={data?.page ?? 1}
|
page={data?.page ?? 1}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import Link from "next/link";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
import type { PagedResult } from "@/types/zxdb";
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
|
||||||
|
|
||||||
export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
||||||
|
|
||||||
|
export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: PagedResult<Item>; initialQ?: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [q, setQ] = useState(initialQ ?? "");
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
@@ -17,7 +18,7 @@ export default function GenreDetailClient({ id, initial, initialQ }: { id: numbe
|
|||||||
<h1 className="mb-0">Genre <span className="badge text-bg-light">#{id}</span></h1>
|
<h1 className="mb-0">Genre <span className="badge text-bg-light">#{id}</span></h1>
|
||||||
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}>
|
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}>
|
||||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
<input className="form-control" placeholder="Search within this genre…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search within this genre…" aria-label="Search within this genre" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<button className="btn btn-primary">Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
|
|||||||
9
src/app/zxdb/genres/[id]/loading.tsx
Normal file
9
src/app/zxdb/genres/[id]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import GenreDetailClient from "./GenreDetail";
|
import GenreDetailClient from "./GenreDetail";
|
||||||
import { entriesByGenre } from "@/server/repo/zxdb";
|
import { entriesByGenre } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Genre" };
|
export const metadata = { title: "ZXDB Genre" };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import GenresSearch from "./GenresSearch";
|
import GenresSearch from "./GenresSearch";
|
||||||
import { searchGenres } from "@/server/repo/zxdb";
|
import { searchGenres } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Genres" };
|
export const metadata = { title: "ZXDB Genres" };
|
||||||
|
|
||||||
|
|||||||
9
src/app/zxdb/issues/[id]/loading.tsx
Normal file
9
src/app/zxdb/issues/[id]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getIssue } from "@/server/repo/zxdb";
|
import { getIssue } from "@/server/repo";
|
||||||
import EntryLink from "@/app/zxdb/components/EntryLink";
|
import EntryLink from "@/app/zxdb/components/EntryLink";
|
||||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-3 d-flex gap-2 flex-wrap">
|
<div className="mb-3 d-flex gap-2 flex-wrap">
|
||||||
<Link className="btn btn-outline-secondary btn-sm" href={`/zxdb/magazines/${issue.magazine.id}`}>← Back to magazine</Link>
|
<Link className="btn btn-outline-secondary btn-sm" href={`/zxdb/magazines/${issue.magazine.id}`}>← Back to magazine</Link>
|
||||||
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link>
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link>
|
||||||
{issue.linkMask && (
|
{issue.linkMask && (
|
||||||
<a className="btn btn-outline-secondary btn-sm" href={issue.linkMask} target="_blank" rel="noreferrer">Issue link</a>
|
<a className="btn btn-outline-secondary btn-sm" href={issue.linkMask} target="_blank" rel="noreferrer">Issue link</a>
|
||||||
@@ -57,6 +57,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
) : (
|
) : (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle">
|
<table className="table table-sm align-middle">
|
||||||
|
<caption className="visually-hidden">Issue references</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 80 }}>Page</th>
|
<th style={{ width: 80 }}>Page</th>
|
||||||
@@ -75,7 +76,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
) : r.labelId ? (
|
) : r.labelId ? (
|
||||||
<Link href={`/zxdb/labels/${r.labelId}`}>{r.labelName ?? r.labelId}</Link>
|
<Link href={`/zxdb/labels/${r.labelId}`}>{r.labelName ?? r.labelId}</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-secondary">—</span>
|
<span className="text-secondary">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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,28 +56,28 @@ 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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</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>
|
||||||
@@ -91,16 +93,15 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
|
|||||||
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
|
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
|
<Badge bg="light" text="dark">{l.labeltypeId ?? "?"}</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
</div>
|
</Row>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={data?.page ?? 1}
|
page={data?.page ?? 1}
|
||||||
|
|||||||
@@ -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,13 +84,12 @@ 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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Website</th>
|
<th>Website</th>
|
||||||
@@ -111,16 +112,14 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
<div className="col-lg-6">
|
<Col lg={6}>
|
||||||
<h5>Licenses</h5>
|
<h5>Licenses</h5>
|
||||||
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
|
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
|
||||||
{initial.label.licenses.length > 0 && (
|
{initial.label.licenses.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>Name</th>
|
<th>Name</th>
|
||||||
@@ -147,35 +146,29 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
</div>
|
</Row>
|
||||||
|
|
||||||
<ul className="nav nav-tabs mt-3">
|
<Nav variant="tabs" className="mt-3" activeKey={tab} onSelect={(k) => setTab(k as "authored" | "published")}>
|
||||||
<li className="nav-item">
|
<Nav.Item>
|
||||||
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
|
<Nav.Link eventKey="authored">Authored</Nav.Link>
|
||||||
</li>
|
</Nav.Item>
|
||||||
<li className="nav-item">
|
<Nav.Item>
|
||||||
<button className={`nav-link ${tab === "published" ? "active" : ""}`} onClick={() => setTab("published")}>Published</button>
|
<Nav.Link eventKey="published">Published</Nav.Link>
|
||||||
</li>
|
</Nav.Item>
|
||||||
</ul>
|
</Nav>
|
||||||
|
|
||||||
<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()}`); }}>
|
<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()}`); }}>
|
||||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
<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)} />
|
||||||
<input className="form-control" placeholder={`Search within ${tab}…`} value={q} onChange={(e) => setQ(e.target.value)} />
|
<Button variant="primary" type="submit">Search</Button>
|
||||||
</div>
|
</Form>
|
||||||
<div className="col-auto">
|
|
||||||
<button className="btn btn-primary">Search</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
{current && current.items.length === 0 && <Alert variant="warning">No entries.</Alert>}
|
||||||
{current && current.items.length > 0 && (
|
{current && current.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<Table striped hover className="align-middle">
|
||||||
<table className="table table-striped table-hover align-middle">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 80 }}>ID</th>
|
<th style={{ width: 80 }}>ID</th>
|
||||||
@@ -214,8 +207,7 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
9
src/app/zxdb/labels/[id]/loading.tsx
Normal file
9
src/app/zxdb/labels/[id]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import LabelDetailClient from "./LabelDetail";
|
import LabelDetailClient from "./LabelDetail";
|
||||||
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
|
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Label" };
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const label = await getLabelById(Number(id));
|
||||||
|
if (!label) return { title: "Label Not Found | ZXDB" };
|
||||||
|
return { title: `${label.name} | ZXDB Label` };
|
||||||
|
}
|
||||||
|
|
||||||
// Depends on searchParams (?page=, ?tab=). Force dynamic so each request renders correctly.
|
// Depends on searchParams (?page=, ?tab=). Force dynamic so each request renders correctly.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LabelsSearch from "./LabelsSearch";
|
import LabelsSearch from "./LabelsSearch";
|
||||||
import { searchLabels } from "@/server/repo/zxdb";
|
import { searchLabels } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Labels" };
|
export const metadata = { title: "ZXDB Labels" };
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 +56,28 @@ 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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</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>
|
||||||
@@ -85,18 +87,17 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((l) => (
|
{data.items.map((l) => (
|
||||||
<tr key={l.id}>
|
<tr key={l.id}>
|
||||||
<td><span className="badge text-bg-light">{l.id}</span></td>
|
<td><Badge bg="light" text="dark">{l.id}</Badge></td>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
|
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
</div>
|
</Row>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={data?.page ?? 1}
|
page={data?.page ?? 1}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import Link from "next/link";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
import type { PagedResult } from "@/types/zxdb";
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
|
||||||
|
|
||||||
export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: Paged<Item>; initialQ?: string }) {
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
||||||
|
|
||||||
|
export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: PagedResult<Item>; initialQ?: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [q, setQ] = useState(initialQ ?? "");
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
@@ -17,7 +18,7 @@ export default function LanguageDetailClient({ id, initial, initialQ }: { id: st
|
|||||||
<h1 className="mb-0">Language <span className="badge text-bg-light">{id}</span></h1>
|
<h1 className="mb-0">Language <span className="badge text-bg-light">{id}</span></h1>
|
||||||
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}>
|
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}>
|
||||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
<input className="form-control" placeholder="Search within this language…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search within this language…" aria-label="Search within this language" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<button className="btn btn-primary">Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
|
|||||||
9
src/app/zxdb/languages/[id]/loading.tsx
Normal file
9
src/app/zxdb/languages/[id]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import LanguageDetailClient from "./LanguageDetail";
|
import LanguageDetailClient from "./LanguageDetail";
|
||||||
import { entriesByLanguage } from "@/server/repo/zxdb";
|
import { entriesByLanguage } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Language" };
|
export const metadata = { title: "ZXDB Language" };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LanguagesSearch from "./LanguagesSearch";
|
import LanguagesSearch from "./LanguagesSearch";
|
||||||
import { searchLanguages } from "@/server/repo/zxdb";
|
import { searchLanguages } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Languages" };
|
export const metadata = { title: "ZXDB Languages" };
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 +56,28 @@ 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>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</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>
|
||||||
@@ -85,18 +87,17 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((m) => (
|
{data.items.map((m) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
<td><span className="badge text-bg-light">#{m.id}</span></td>
|
<td><Badge bg="light" text="dark">#{m.id}</Badge></td>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
|
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Col>
|
||||||
</div>
|
</Row>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={data?.page ?? 1}
|
page={data?.page ?? 1}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ type Item = {
|
|||||||
languageId: string | null;
|
languageId: string | null;
|
||||||
languageName?: string | null;
|
languageName?: string | null;
|
||||||
};
|
};
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
import type { PagedResult } from "@/types/zxdb";
|
||||||
|
|
||||||
export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
|
export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: PagedResult<Item>; initialQ?: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [q, setQ] = useState(initialQ ?? "");
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
@@ -29,7 +29,7 @@ export default function MachineTypeDetailClient({ id, initial, initialQ }: { id:
|
|||||||
<h1 className="mb-0">{machineName ?? "Machine Type"} <span className="badge text-bg-light">#{id}</span></h1>
|
<h1 className="mb-0">{machineName ?? "Machine Type"} <span className="badge text-bg-light">#{id}</span></h1>
|
||||||
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}>
|
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}>
|
||||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
<input className="form-control" placeholder="Search within this machine type…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search within this machine type…" aria-label="Search within this machine type" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<button className="btn btn-primary">Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
|
|||||||
9
src/app/zxdb/machinetypes/[id]/loading.tsx
Normal file
9
src/app/zxdb/machinetypes/[id]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import MachineTypeDetailClient from "./MachineTypeDetail";
|
import MachineTypeDetailClient from "./MachineTypeDetail";
|
||||||
import { entriesByMachinetype } from "@/server/repo/zxdb";
|
import { entriesByMachinetype } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Machine Type" };
|
export const metadata = { title: "ZXDB Machine Type" };
|
||||||
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
|
// Depends on searchParams (?page=). Force dynamic so each page renders correctly.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import MachineTypesSearch from "./MachineTypesSearch";
|
import MachineTypesSearch from "./MachineTypesSearch";
|
||||||
import { searchMachinetypes } from "@/server/repo/zxdb";
|
import { searchMachinetypes } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Machine Types" };
|
export const metadata = { title: "ZXDB Machine Types" };
|
||||||
|
|
||||||
|
|||||||
9
src/app/zxdb/magazines/[id]/loading.tsx
Normal file
9
src/app/zxdb/magazines/[id]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getMagazine } from "@/server/repo/zxdb";
|
import { getMagazine } from "@/server/repo";
|
||||||
|
import { Link45deg, Archive } from "react-bootstrap-icons";
|
||||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Magazine" };
|
export const metadata = { title: "ZXDB Magazine" };
|
||||||
@@ -28,7 +29,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
<div className="text-secondary mb-3">Language: {mag.languageId}</div>
|
<div className="text-secondary mb-3">Language: {mag.languageId}</div>
|
||||||
|
|
||||||
<div className="mb-3 d-flex gap-2 flex-wrap">
|
<div className="mb-3 d-flex gap-2 flex-wrap">
|
||||||
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">← Back to list</Link>
|
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">← Back to list</Link>
|
||||||
{mag.linkSite && (
|
{mag.linkSite && (
|
||||||
<a className="btn btn-outline-secondary btn-sm" href={mag.linkSite} target="_blank" rel="noreferrer">
|
<a className="btn btn-outline-secondary btn-sm" href={mag.linkSite} target="_blank" rel="noreferrer">
|
||||||
Official site
|
Official site
|
||||||
@@ -42,6 +43,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
) : (
|
) : (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle">
|
<table className="table table-sm align-middle">
|
||||||
|
<caption className="visually-hidden">Magazine issues</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 200 }}>Issue</th>
|
<th style={{ width: 200 }}>Issue</th>
|
||||||
@@ -71,13 +73,13 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
{i.linkMask && (
|
{i.linkMask && (
|
||||||
<a className="btn btn-outline-secondary btn-sm" href={i.linkMask} target="_blank" rel="noreferrer" title="Link">
|
<a className="btn btn-outline-secondary btn-sm" href={i.linkMask} target="_blank" rel="noreferrer" title="Link">
|
||||||
<span className="bi bi-link-45deg" aria-hidden />
|
<Link45deg aria-hidden />
|
||||||
<span className="visually-hidden">Link</span>
|
<span className="visually-hidden">Link</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{i.archiveMask && (
|
{i.archiveMask && (
|
||||||
<a className="btn btn-outline-secondary btn-sm" href={i.archiveMask} target="_blank" rel="noreferrer" title="Archive">
|
<a className="btn btn-outline-secondary btn-sm" href={i.archiveMask} target="_blank" rel="noreferrer" title="Archive">
|
||||||
<span className="bi bi-archive" aria-hidden />
|
<Archive aria-hidden />
|
||||||
<span className="visually-hidden">Archive</span>
|
<span className="visually-hidden">Archive</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { listMagazines } from "@/server/repo/zxdb";
|
import { listMagazines } from "@/server/repo";
|
||||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Magazines" };
|
export const metadata = { title: "ZXDB Magazines" };
|
||||||
@@ -17,6 +17,14 @@ export default async function Page({
|
|||||||
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
|
||||||
const data = await listMagazines({ q, page, pageSize: 20 });
|
const data = await listMagazines({ q, page, pageSize: 20 });
|
||||||
|
const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
||||||
|
|
||||||
|
function makeHref(p: number) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
params.set("page", String(p));
|
||||||
|
return `/zxdb/magazines?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -54,6 +62,7 @@ export default async function Page({
|
|||||||
<div className="col-lg-9">
|
<div className="col-lg-9">
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped table-hover align-middle">
|
<table className="table table-striped table-hover align-middle">
|
||||||
|
<caption className="visually-hidden">Magazines search results</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
@@ -81,37 +90,21 @@ export default async function Page({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} />
|
{totalPages > 1 && (
|
||||||
</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">
|
<nav className="mt-3" aria-label="Pagination">
|
||||||
<ul className="pagination">
|
<ul className="pagination">
|
||||||
<li className={`page-item ${page <= 1 ? "disabled" : ""}`}>
|
<li className={`page-item ${page <= 1 ? "disabled" : ""}`}>
|
||||||
<Link className="page-link" href={makeHref(Math.max(1, page - 1))}>
|
<Link className="page-link" href={makeHref(Math.max(1, page - 1))}>Previous</Link>
|
||||||
Previous
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
<li className="page-item disabled">
|
<li className="page-item disabled">
|
||||||
<span className="page-link">Page {page} of {totalPages}</span>
|
<span className="page-link">Page {page} of {totalPages}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className={`page-item ${page >= totalPages ? "disabled" : ""}`}>
|
<li className={`page-item ${page >= totalPages ? "disabled" : ""}`}>
|
||||||
<Link className="page-link" href={makeHref(Math.min(totalPages, page + 1))}>
|
<Link className="page-link" href={makeHref(Math.min(totalPages, page + 1))}>Next</Link>
|
||||||
Next
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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,7 +59,8 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="row g-3">
|
<section>
|
||||||
|
<div className="row g-3">
|
||||||
<div className="col-lg-8">
|
<div className="col-lg-8">
|
||||||
<TapeIdentifier />
|
<TapeIdentifier />
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +70,7 @@ export default async function Page() {
|
|||||||
The file stays in your browser — only its hash is sent.
|
The file stays in your browser — only its hash is sent.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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,7 +142,8 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="row g-3">
|
<section>
|
||||||
|
<div className="row g-3">
|
||||||
<div className="col-lg-7">
|
<div className="col-lg-7">
|
||||||
<div className="card h-100 shadow-sm">
|
<div className="card h-100 shadow-sm">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
@@ -167,6 +171,7 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,20 +538,18 @@ 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>
|
||||||
@@ -572,7 +564,7 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
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><span className="badge text-bg-secondary">{f.type.name}</span></td>
|
<td><Badge bg="secondary">{f.type.name}</Badge></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>
|
||||||
@@ -587,13 +579,12 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
|
|||||||
9
src/app/zxdb/releases/[entryId]/[releaseSeq]/loading.tsx
Normal file
9
src/app/zxdb/releases/[entryId]/[releaseSeq]/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
import ReleaseDetailClient from "./ReleaseDetail";
|
import ReleaseDetailClient from "./ReleaseDetail";
|
||||||
import { getReleaseDetail } from "@/server/repo/zxdb";
|
import { getReleaseDetail } from "@/server/repo";
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "ZXDB Release",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }): Promise<Metadata> {
|
||||||
|
const { entryId, releaseSeq } = await params;
|
||||||
|
const data = await getReleaseDetail(Number(entryId), Number(releaseSeq));
|
||||||
|
if (!data) return { title: "Release Not Found | ZXDB" };
|
||||||
|
return { title: `${data.entry.title} #${releaseSeq} | ZXDB Release` };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }) {
|
||||||
const { entryId, releaseSeq } = await params;
|
const { entryId, releaseSeq } = await params;
|
||||||
const entryIdNum = Number(entryId);
|
const data = await getReleaseDetail(Number(entryId), Number(releaseSeq));
|
||||||
const releaseSeqNum = Number(releaseSeq);
|
if (!data) notFound();
|
||||||
const data = await getReleaseDetail(entryIdNum, releaseSeqNum);
|
|
||||||
return <ReleaseDetailClient data={data} />;
|
return <ReleaseDetailClient data={data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import ReleasesExplorer from "./ReleasesExplorer";
|
import ReleasesExplorer from "./ReleasesExplorer";
|
||||||
import { listCasetypes, listFiletypes, listLanguages, listMachinetypes, listSchemetypes, listSourcetypes, searchReleases } from "@/server/repo/zxdb";
|
import { listCasetypes, listFiletypes, listLanguages, listMachinetypes, listSchemetypes, listSourcetypes, searchReleases } from "@/server/repo";
|
||||||
|
import { parseIdList } from "@/utils/params";
|
||||||
|
import { serialize } from "@/utils/serialize";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "ZXDB Releases",
|
title: "ZXDB Releases",
|
||||||
@@ -7,16 +9,6 @@ export const metadata = {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
function parseIdList(value: string | string[] | undefined) {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const raw = Array.isArray(value) ? value.join(",") : value;
|
|
||||||
const ids = raw
|
|
||||||
.split(",")
|
|
||||||
.map((id) => Number(id.trim()))
|
|
||||||
.filter((id) => Number.isFinite(id) && id > 0);
|
|
||||||
return ids.length ? ids : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const hasParams = Object.values(sp).some((value) => value !== undefined);
|
const hasParams = Object.values(sp).some((value) => value !== undefined);
|
||||||
@@ -47,20 +39,17 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||||||
listCasetypes(),
|
listCasetypes(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Ensure the object passed to a Client Component is a plain JSON value
|
|
||||||
const initialPlain = JSON.parse(JSON.stringify(initial));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReleasesExplorer
|
<ReleasesExplorer
|
||||||
initial={initialPlain}
|
initial={serialize(initial)}
|
||||||
initialLists={{
|
initialLists={serialize({
|
||||||
languages: JSON.parse(JSON.stringify(langs)),
|
languages: langs,
|
||||||
machinetypes: JSON.parse(JSON.stringify(machines)),
|
machinetypes: machines,
|
||||||
filetypes: JSON.parse(JSON.stringify(filetypes)),
|
filetypes,
|
||||||
schemetypes: JSON.parse(JSON.stringify(schemes)),
|
schemetypes: schemes,
|
||||||
sourcetypes: JSON.parse(JSON.stringify(sources)),
|
sourcetypes: sources,
|
||||||
casetypes: JSON.parse(JSON.stringify(cases)),
|
casetypes: cases,
|
||||||
}}
|
})}
|
||||||
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
|
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
|
||||||
initialUrlHasParams={hasParams}
|
initialUrlHasParams={hasParams}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ export default function NavbarClient() {
|
|||||||
return (
|
return (
|
||||||
<Navbar expand="lg" bg="primary" data-bs-theme="dark" sticky="top" className="navbar">
|
<Navbar expand="lg" bg="primary" data-bs-theme="dark" sticky="top" className="navbar">
|
||||||
<Container fluid>
|
<Container fluid>
|
||||||
<Link className="navbar-brand" href="/">SpecNext Explorer</Link>
|
<Navbar.Brand as={Link} href="/">SpecNext Explorer</Navbar.Brand>
|
||||||
<Navbar.Toggle aria-controls="navbarSupportedContent" />
|
<Navbar.Toggle aria-controls="navbarSupportedContent" />
|
||||||
<Navbar.Collapse id="navbarSupportedContent">
|
<Navbar.Collapse id="navbarSupportedContent">
|
||||||
<Nav className="me-auto mb-2 mb-lg-0">
|
<Nav className="me-auto mb-2 mb-lg-0">
|
||||||
<Link className="nav-link" href="/">Home</Link>
|
<Nav.Link as={Link} href="/">Home</Nav.Link>
|
||||||
<Link className="nav-link" href="/registers">Registers</Link>
|
<Nav.Link as={Link} href="/registers">Registers</Nav.Link>
|
||||||
<Link className="nav-link" href="/zxdb">ZXDB</Link>
|
<Nav.Link as={Link} href="/zxdb">ZXDB</Nav.Link>
|
||||||
</Nav>
|
</Nav>
|
||||||
|
|
||||||
<ThemeDropdown />
|
<ThemeDropdown />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import * as Icon from "react-bootstrap-icons";
|
import { SunFill, MoonStarsFill, CircleHalf, Check2 } from "react-bootstrap-icons";
|
||||||
import { Nav, Dropdown } from "react-bootstrap";
|
import { Nav, Dropdown } from "react-bootstrap";
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "auto";
|
type Theme = "light" | "dark" | "auto";
|
||||||
@@ -13,7 +13,7 @@ const getCookie = (name: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setCookie = (name: string, value: string) => {
|
const setCookie = (name: string, value: string) => {
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax; Domain=specnext.dev`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const prefersDark = () =>
|
const prefersDark = () =>
|
||||||
@@ -62,12 +62,12 @@ export default function ThemeDropdown() {
|
|||||||
|
|
||||||
const isActive = (t: Theme) => theme === t;
|
const isActive = (t: Theme) => theme === t;
|
||||||
const ToggleIcon = !mounted
|
const ToggleIcon = !mounted
|
||||||
? Icon.CircleHalf
|
? CircleHalf
|
||||||
: theme === "dark"
|
: theme === "dark"
|
||||||
? Icon.MoonStarsFill
|
? MoonStarsFill
|
||||||
: theme === "light"
|
: theme === "light"
|
||||||
? Icon.SunFill
|
? SunFill
|
||||||
: Icon.CircleHalf;
|
: CircleHalf;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Nav className="ms-md-auto">
|
<Nav className="ms-md-auto">
|
||||||
@@ -78,19 +78,19 @@ export default function ThemeDropdown() {
|
|||||||
</Dropdown.Toggle>
|
</Dropdown.Toggle>
|
||||||
<Dropdown.Menu aria-labelledby="bd-theme-text">
|
<Dropdown.Menu aria-labelledby="bd-theme-text">
|
||||||
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("light")} onClick={() => choose("light")}>
|
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("light")} onClick={() => choose("light")}>
|
||||||
<Icon.SunFill />
|
<SunFill />
|
||||||
<span className="ms-2">Light</span>
|
<span className="ms-2">Light</span>
|
||||||
{isActive("light") && <Icon.Check2 className="ms-auto" aria-hidden="true" />}
|
{isActive("light") && <Check2 className="ms-auto" aria-hidden="true" />}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("dark")} onClick={() => choose("dark")}>
|
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("dark")} onClick={() => choose("dark")}>
|
||||||
<Icon.MoonStarsFill />
|
<MoonStarsFill />
|
||||||
<span className="ms-2">Dark</span>
|
<span className="ms-2">Dark</span>
|
||||||
{isActive("dark") && <Icon.Check2 className="ms-auto" aria-hidden="true" />}
|
{isActive("dark") && <Check2 className="ms-auto" aria-hidden="true" />}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("auto")} onClick={() => choose("auto")}>
|
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("auto")} onClick={() => choose("auto")}>
|
||||||
<Icon.CircleHalf />
|
<CircleHalf />
|
||||||
<span className="ms-2">Auto</span>
|
<span className="ms-2">Auto</span>
|
||||||
{isActive("auto") && <Icon.Check2 className="ms-auto" aria-hidden="true" />}
|
{isActive("auto") && <Check2 className="ms-auto" aria-hidden="true" />}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export function middleware(request) {
|
|
||||||
const { method, nextUrl } = request
|
|
||||||
|
|
||||||
// Filter out internal Next.js assets if desired
|
|
||||||
if (!nextUrl.pathname.startsWith('/_next')) {
|
|
||||||
console.log(`${method} ${nextUrl.pathname}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next()
|
|
||||||
}
|
|
||||||
11
src/middleware.ts
Normal file
11
src/middleware.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse, NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const { method, nextUrl } = request;
|
||||||
|
if (!nextUrl.pathname.startsWith('/_next')) {
|
||||||
|
console.log(`${method} ${nextUrl.pathname}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
3
src/server/repo/index.ts
Normal file
3
src/server/repo/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Barrel re-export — enables import from "@/server/repo" and
|
||||||
|
// sets up for incremental per-domain splitting.
|
||||||
|
export * from "./zxdb";
|
||||||
@@ -1,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');
|
||||||
registers = await parseNextReg(fileContent);
|
return parseNextReg(fileContent);
|
||||||
// }
|
});
|
||||||
return registers;
|
|
||||||
}
|
|
||||||
|
|||||||
25
src/types/zxdb.ts
Normal file
25
src/types/zxdb.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** Paginated API response wrapper */
|
||||||
|
export type PagedResult<T> = {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Facet item returned by search APIs */
|
||||||
|
export type FacetItem<T = number> = {
|
||||||
|
id: T;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Entry search facets */
|
||||||
|
export type EntryFacets = {
|
||||||
|
genres: FacetItem<number>[];
|
||||||
|
languages: FacetItem<string>[];
|
||||||
|
machinetypes: FacetItem<number>[];
|
||||||
|
flags: { hasAliases: number; hasOrigins: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Entry search scope */
|
||||||
|
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
|
||||||
29
src/utils/params.ts
Normal file
29
src/utils/params.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Parse a comma-separated string of positive integer IDs.
|
||||||
|
* Accepts either a plain string or a string[] (from searchParams).
|
||||||
|
*/
|
||||||
|
export function parseIdList(value: string | string[] | undefined): number[] | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const raw = Array.isArray(value) ? value.join(",") : value;
|
||||||
|
const ids = raw
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default machine type IDs for entry/release searches */
|
||||||
|
export const preferredMachineIds = [27, 26, 8, 9];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse machine type IDs from a comma-separated string,
|
||||||
|
* falling back to preferredMachineIds when empty.
|
||||||
|
*/
|
||||||
|
export function parseMachineIds(value?: string): number[] {
|
||||||
|
if (!value) return preferredMachineIds.slice();
|
||||||
|
const ids = value
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return ids.length ? ids : preferredMachineIds.slice();
|
||||||
|
}
|
||||||
93
src/utils/register_helpers.ts
Normal file
93
src/utils/register_helpers.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
type RegisterLike = {
|
||||||
|
description: string;
|
||||||
|
text: string;
|
||||||
|
modes: { text: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns true if a line contains useful register info (not bitfield/access markers) */
|
||||||
|
export function isInfoLine(line: string): boolean {
|
||||||
|
return (
|
||||||
|
line.length > 0 &&
|
||||||
|
!line.startsWith("//") &&
|
||||||
|
!line.startsWith("(R") &&
|
||||||
|
!line.startsWith("(W") &&
|
||||||
|
!line.startsWith("(R/W") &&
|
||||||
|
!line.startsWith("*") &&
|
||||||
|
!/^bits?\s+\d/i.test(line)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a single-line summary string for metadata descriptions */
|
||||||
|
export function buildRegisterSummary(register: RegisterLike): string {
|
||||||
|
const trimLine = (line: string) => line.trim();
|
||||||
|
|
||||||
|
const modeLines = register.modes
|
||||||
|
.flatMap((mode) => mode.text.split("\n"))
|
||||||
|
.map(trimLine)
|
||||||
|
.filter(isInfoLine);
|
||||||
|
const textLines = register.text.split("\n").map(trimLine).filter(isInfoLine);
|
||||||
|
const descriptionLines = register.description.split("\n").map(trimLine).filter(isInfoLine);
|
||||||
|
|
||||||
|
const rawSummary = [...textLines, ...modeLines, ...descriptionLines]
|
||||||
|
.join(" ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!rawSummary) return "Spectrum Next register details and bit-level behavior.";
|
||||||
|
if (rawSummary.length <= 180) return rawSummary;
|
||||||
|
return `${rawSummary.slice(0, 177).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build deduplicated summary lines for OG image rendering */
|
||||||
|
export function buildRegisterSummaryLines(register: RegisterLike): string[] {
|
||||||
|
const normalizeLines = (raw: string) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const rawLines = raw.split("\n");
|
||||||
|
for (const rawLine of rawLines) {
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
if (lines.length > 0 && lines[lines.length - 1] !== "") {
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isInfoLine(trimmed)) {
|
||||||
|
lines.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const textLines = normalizeLines(register.text);
|
||||||
|
const modeLines = register.modes.flatMap((mode) => normalizeLines(mode.text));
|
||||||
|
const descriptionLines = normalizeLines(register.description);
|
||||||
|
|
||||||
|
const combined: string[] = [];
|
||||||
|
const appendBlock = (block: string[]) => {
|
||||||
|
if (block.length === 0) return;
|
||||||
|
if (combined.length > 0 && combined[combined.length - 1] !== "") {
|
||||||
|
combined.push("");
|
||||||
|
}
|
||||||
|
combined.push(...block);
|
||||||
|
};
|
||||||
|
|
||||||
|
appendBlock(textLines);
|
||||||
|
appendBlock(modeLines);
|
||||||
|
appendBlock(descriptionLines);
|
||||||
|
|
||||||
|
const deduped: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const line of combined) {
|
||||||
|
if (!line) {
|
||||||
|
if (deduped.length > 0 && deduped[deduped.length - 1] !== "") {
|
||||||
|
deduped.push("");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(line)) continue;
|
||||||
|
seen.add(line);
|
||||||
|
deduped.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped.length > 0 ? deduped : ["Spectrum Next register details and bit-level behavior."];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
91
src/utils/register_parsers/reg_44.ts
Normal file
91
src/utils/register_parsers/reg_44.ts
Normal 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();
|
||||||
|
};
|
||||||
@@ -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 || [];
|
||||||
|
if (detailHasContent(detail)) {
|
||||||
reg.modes.push(detail);
|
reg.modes.push(detail);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
// Skip the initial blank detail that precedes the first mode header.
|
||||||
|
if (detailHasContent(detail)) {
|
||||||
reg.modes.push(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).
|
||||||
|
if (detailHasContent(detail)) {
|
||||||
reg.modes.push(detail);
|
reg.modes.push(detail);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
8
src/utils/serialize.ts
Normal file
8
src/utils/serialize.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user