diff --git a/data/nextreg.txt b/data/nextreg.txt index ca44b19..e8eaddd 100644 --- a/data/nextreg.txt +++ b/data/nextreg.txt @@ -85,9 +85,9 @@ Generally a set bit indicates the property is asserted 0x04 (04) => Config Mapping config mode only, bootrom disabled (W) - bit 7 = Reserved, must be 0 - bits 6:0 = 16K SRAM bank mapped to 0x0000-0x3FFF (hard reset = 0) - ** Even multiplies of 256K are unreliable if storing data in sram for the next core started. + bits 7: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. + ** Bit 7 ignored except on issue 5 pcb 0x05 (05) => Peripheral 1 Setting (R/W) @@ -174,15 +174,16 @@ Joystick modes: 01 = Multiface 128 v87.2 (enable port 0xBF, disable port 0x3F) 10 = Multiface 128 v87.12 (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 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) 00 = low dpi 01 = default 10 = medium dpi 11 = high dpi + * only affects future writes to port 0xE7 0x0B (11) => Joystick I/O Mode (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 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 + 0011 = ZXN Issue 5, XC7A35T-2CSG324, 256Mbit MX25L25645G, 32bit spi, 64K*34 core size 0x10 (16) => Core Boot (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 from the fallback colour in nextreg 0x4A. -0x43 (67) => ULA Palette Control +0x43 (67) => Palette Control (R/W) bit 7 = Disable palette write auto-increment (soft reset = 0) 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 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 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) 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 27 = port 183b,193b,1a3b,1b3b,1c3b,1d3b,1e3b,1f3b z80 ctc ... + ... bit 31 = register reset mode (soft or hard reset selection) ----- The internal port decoding enables always apply. @@ -1202,7 +1206,7 @@ progress is made in the main program. -- 0xF0 (240) => XDEV CMD -R/W Issue 4 Only - (soft reset = 0x80) +R/W Issues 4 and 5 Only - (soft reset = 0x80) Select Mode (R) 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 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 ** bits 6:0 = XADC DRP register address DADDR * 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 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 * DRP reads store result here, DRP writes take value from here 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 * DRP reads store result here, DRP writes take value from here diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..85d21db --- /dev/null +++ b/docs/todo.md @@ -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`, `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 ``, ``, `
`, ``, `
`, ``, `
` throughout instead of `
` | +| `zxdb/page.tsx` | Raw ``, `
`, local `Pagination` component instead of shared one | +| `magazines/[id]/page.tsx` | Raw `
` | +| `issues/[id]/page.tsx` | Raw `
` | +| `page.tsx` (home) | Raw `
` instead of `` | +| `TapeIdentifier.tsx` | Raw `
` 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`, 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 `` instead of raw ``. + +### 🎨 UI: Home page uses `bi bi-*` CSS classes instead of react-bootstrap-icons + +**File:** `src/app/page.tsx:12,29` + +Uses `` 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 `
` elements + +Data tables throughout the ZXDB explorer have no ``, 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. diff --git a/src/app/api/zxdb/availabletypes/route.ts b/src/app/api/zxdb/availabletypes/route.ts index e05a8a3..d46ec47 100644 --- a/src/app/api/zxdb/availabletypes/route.ts +++ b/src/app/api/zxdb/availabletypes/route.ts @@ -1,4 +1,4 @@ -import { listAvailabletypes } from "@/server/repo/zxdb"; +import { listAvailabletypes } from "@/server/repo"; export async function GET() { const items = await listAvailabletypes(); diff --git a/src/app/api/zxdb/casetypes/route.ts b/src/app/api/zxdb/casetypes/route.ts index d6bc820..a5ee7c3 100644 --- a/src/app/api/zxdb/casetypes/route.ts +++ b/src/app/api/zxdb/casetypes/route.ts @@ -1,4 +1,4 @@ -import { listCasetypes } from "@/server/repo/zxdb"; +import { listCasetypes } from "@/server/repo"; export async function GET() { const items = await listCasetypes(); diff --git a/src/app/api/zxdb/currencies/route.ts b/src/app/api/zxdb/currencies/route.ts index 4b0ef61..edb36ac 100644 --- a/src/app/api/zxdb/currencies/route.ts +++ b/src/app/api/zxdb/currencies/route.ts @@ -1,4 +1,4 @@ -import { listCurrencies } from "@/server/repo/zxdb"; +import { listCurrencies } from "@/server/repo"; export async function GET() { const items = await listCurrencies(); diff --git a/src/app/api/zxdb/download/route.ts b/src/app/api/zxdb/download/route.ts index aa77d51..742f342 100644 --- a/src/app/api/zxdb/download/route.ts +++ b/src/app/api/zxdb/download/route.ts @@ -23,6 +23,11 @@ export async function GET(req: NextRequest) { return new NextResponse("Invalid source or mirroring not enabled", { status: 400 }); } + // Defense-in-depth: reject obvious traversal before joining + if (filePath.includes("..")) { + return new NextResponse("Forbidden", { status: 403 }); + } + // Security: Ensure path doesn't escape baseDir const absolutePath = path.normalize(path.join(baseDir, filePath)); if (!absolutePath.startsWith(path.normalize(baseDir))) { diff --git a/src/app/api/zxdb/entries/[id]/route.ts b/src/app/api/zxdb/entries/[id]/route.ts index e7b5ba1..5233905 100644 --- a/src/app/api/zxdb/entries/[id]/route.ts +++ b/src/app/api/zxdb/entries/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; 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() }); diff --git a/src/app/api/zxdb/filetypes/route.ts b/src/app/api/zxdb/filetypes/route.ts index 2366d8e..3785965 100644 --- a/src/app/api/zxdb/filetypes/route.ts +++ b/src/app/api/zxdb/filetypes/route.ts @@ -1,4 +1,4 @@ -import { listFiletypes } from "@/server/repo/zxdb"; +import { listFiletypes } from "@/server/repo"; export async function GET() { const items = await listFiletypes(); diff --git a/src/app/api/zxdb/genres/[id]/route.ts b/src/app/api/zxdb/genres/[id]/route.ts index d3a643a..36a2ad5 100644 --- a/src/app/api/zxdb/genres/[id]/route.ts +++ b/src/app/api/zxdb/genres/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; 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 querySchema = z.object({ diff --git a/src/app/api/zxdb/genres/route.ts b/src/app/api/zxdb/genres/route.ts index 855b6cb..294f23f 100644 --- a/src/app/api/zxdb/genres/route.ts +++ b/src/app/api/zxdb/genres/route.ts @@ -1,4 +1,4 @@ -import { listGenres } from "@/server/repo/zxdb"; +import { listGenres } from "@/server/repo"; export async function GET() { const data = await listGenres(); diff --git a/src/app/api/zxdb/labels/[id]/route.ts b/src/app/api/zxdb/labels/[id]/route.ts index 58f749e..6b2bfab 100644 --- a/src/app/api/zxdb/labels/[id]/route.ts +++ b/src/app/api/zxdb/labels/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; 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 querySchema = z.object({ diff --git a/src/app/api/zxdb/labels/search/route.ts b/src/app/api/zxdb/labels/search/route.ts index a5617c1..008fab0 100644 --- a/src/app/api/zxdb/labels/search/route.ts +++ b/src/app/api/zxdb/labels/search/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; import { z } from "zod"; -import { searchLabels } from "@/server/repo/zxdb"; +import { searchLabels } from "@/server/repo"; const querySchema = z.object({ q: z.string().optional(), diff --git a/src/app/api/zxdb/languages/[id]/route.ts b/src/app/api/zxdb/languages/[id]/route.ts index a67565d..fe59f73 100644 --- a/src/app/api/zxdb/languages/[id]/route.ts +++ b/src/app/api/zxdb/languages/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; 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 querySchema = z.object({ diff --git a/src/app/api/zxdb/languages/route.ts b/src/app/api/zxdb/languages/route.ts index e1b47db..2a7023f 100644 --- a/src/app/api/zxdb/languages/route.ts +++ b/src/app/api/zxdb/languages/route.ts @@ -1,4 +1,4 @@ -import { listLanguages } from "@/server/repo/zxdb"; +import { listLanguages } from "@/server/repo"; export async function GET() { const data = await listLanguages(); diff --git a/src/app/api/zxdb/machinetypes/[id]/route.ts b/src/app/api/zxdb/machinetypes/[id]/route.ts index 3d6803e..e288a0c 100644 --- a/src/app/api/zxdb/machinetypes/[id]/route.ts +++ b/src/app/api/zxdb/machinetypes/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; 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 querySchema = z.object({ diff --git a/src/app/api/zxdb/machinetypes/route.ts b/src/app/api/zxdb/machinetypes/route.ts index 5800936..3e2ecd5 100644 --- a/src/app/api/zxdb/machinetypes/route.ts +++ b/src/app/api/zxdb/machinetypes/route.ts @@ -1,4 +1,4 @@ -import { listMachinetypes } from "@/server/repo/zxdb"; +import { listMachinetypes } from "@/server/repo"; export async function GET() { const data = await listMachinetypes(); diff --git a/src/app/api/zxdb/releases/search/route.ts b/src/app/api/zxdb/releases/search/route.ts index 3345876..d8b0658 100644 --- a/src/app/api/zxdb/releases/search/route.ts +++ b/src/app/api/zxdb/releases/search/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; -import { searchReleases } from "@/server/repo/zxdb"; +import { searchReleases } from "@/server/repo"; +import { parseIdList } from "@/utils/params"; const querySchema = z.object({ q: z.string().optional(), @@ -17,15 +18,6 @@ const querySchema = z.object({ 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) { const { searchParams } = new URL(req.url); const parsed = querySchema.safeParse({ diff --git a/src/app/api/zxdb/roletypes/route.ts b/src/app/api/zxdb/roletypes/route.ts index afe6e0b..eee694a 100644 --- a/src/app/api/zxdb/roletypes/route.ts +++ b/src/app/api/zxdb/roletypes/route.ts @@ -1,4 +1,4 @@ -import { listRoletypes } from "@/server/repo/zxdb"; +import { listRoletypes } from "@/server/repo"; export async function GET() { const items = await listRoletypes(); diff --git a/src/app/api/zxdb/schemetypes/route.ts b/src/app/api/zxdb/schemetypes/route.ts index 13747f3..8bf60c1 100644 --- a/src/app/api/zxdb/schemetypes/route.ts +++ b/src/app/api/zxdb/schemetypes/route.ts @@ -1,4 +1,4 @@ -import { listSchemetypes } from "@/server/repo/zxdb"; +import { listSchemetypes } from "@/server/repo"; export async function GET() { const items = await listSchemetypes(); diff --git a/src/app/api/zxdb/search/route.ts b/src/app/api/zxdb/search/route.ts index 71ebeb3..2457880 100644 --- a/src/app/api/zxdb/search/route.ts +++ b/src/app/api/zxdb/search/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; 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({ q: z.string().optional(), @@ -19,15 +20,6 @@ const querySchema = z.object({ 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) { const { searchParams } = new URL(req.url); const parsed = querySchema.safeParse({ diff --git a/src/app/api/zxdb/sourcetypes/route.ts b/src/app/api/zxdb/sourcetypes/route.ts index cd4d848..2525e52 100644 --- a/src/app/api/zxdb/sourcetypes/route.ts +++ b/src/app/api/zxdb/sourcetypes/route.ts @@ -1,4 +1,4 @@ -import { listSourcetypes } from "@/server/repo/zxdb"; +import { listSourcetypes } from "@/server/repo"; export async function GET() { const items = await listSourcetypes(); diff --git a/src/app/page.module.css b/src/app/page.module.css deleted file mode 100644 index 581f251..0000000 --- a/src/app/page.module.css +++ /dev/null @@ -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();*/ -/* }*/ -/*}*/ diff --git a/src/app/page.tsx b/src/app/page.tsx index 64934b5..594de16 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { Collection, Cpu } from "react-bootstrap-icons"; export default function Home() { return ( @@ -8,7 +9,7 @@ export default function Home() {
- +

ZXDB Explorer

Search entries, releases, magazines, and labels.

@@ -26,7 +27,7 @@ export default function Home() {
- +

NextReg Explorer

Browse Spectrum Next registers and bitfields.

diff --git a/src/app/registers/RegisterDetail.tsx b/src/app/registers/RegisterDetail.tsx index b8f7c20..ee798bc 100644 --- a/src/app/registers/RegisterDetail.tsx +++ b/src/app/registers/RegisterDetail.tsx @@ -5,7 +5,7 @@ import { Col, Card, Tabs, Tab, Button, Modal } from 'react-bootstrap'; import { Register } from '@/utils/register_parser'; import { renderAccess } from './RegisterBrowser'; 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. @@ -24,18 +24,18 @@ export default function RegisterDetail({ {register.hex_address} ( {register.dec_address} )   - {register.name} {register.issue_4_only && Issue 4 Only} + {register.name} {register.issue_4_only && Issues 4 & 5 Only}
- +   - +  
diff --git a/src/app/registers/[hex]/opengraph-image.tsx b/src/app/registers/[hex]/opengraph-image.tsx index 20e1d4d..64855cb 100644 --- a/src/app/registers/[hex]/opengraph-image.tsx +++ b/src/app/registers/[hex]/opengraph-image.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from 'next/og'; import { getRegisters } from '@/services/register.service'; +import { buildRegisterSummaryLines } from '@/utils/register_helpers'; export const runtime = 'nodejs'; @@ -10,70 +11,6 @@ export const size = { 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(); - 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) => { if (word.length <= maxLineLength) return [word]; const chunks: string[] = []; @@ -171,8 +108,8 @@ export default async function Image({ params }: { params: Promise<{ hex: string lineHeight: 1.05, }} > - {titleLines.map(line => ( -
{line}
+ {titleLines.map((line, idx) => ( +
{line}
))}
- {summaryLines.map(line => ( -
{line}
+ {summaryLines.map((line, idx) => ( +
{line}
))}
diff --git a/src/app/registers/[hex]/page.tsx b/src/app/registers/[hex]/page.tsx index 2ac5632..68c89a0 100644 --- a/src/app/registers/[hex]/page.tsx +++ b/src/app/registers/[hex]/page.tsx @@ -5,27 +5,7 @@ import RegisterDetail from '@/app/registers/RegisterDetail'; import {Container, Row} from "react-bootstrap"; import { getRegisters } from '@/services/register.service'; import {env} from "@/env"; - -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()}...`; -}; +import { buildRegisterSummary } from "@/utils/register_helpers"; export async function generateMetadata({ params }: { params: Promise<{ hex: string }> }): Promise { const registers = await getRegisters(); diff --git a/src/app/zxdb/TapeIdentifier.tsx b/src/app/zxdb/TapeIdentifier.tsx index e82b651..0860c35 100644 --- a/src/app/zxdb/TapeIdentifier.tsx +++ b/src/app/zxdb/TapeIdentifier.tsx @@ -2,9 +2,11 @@ import { useState, useRef, useCallback } from "react"; import Link from "next/link"; +import { Card, Table, Alert, Badge, Spinner, Button } from "react-bootstrap"; +import { Cassette, CloudArrowUp, PersonFill, TagFill, CpuFill, ArrowRight } from "react-bootstrap-icons"; import { computeMd5 } from "@/utils/md5"; import { identifyTape } from "./actions"; -import type { TapeMatch } from "@/server/repo/zxdb"; +import type { TapeMatch } from "@/server/repo"; const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"]; @@ -79,12 +81,12 @@ export default function TapeIdentifier() { // Dropzone view (idle, hashing, identifying, error) if (state.kind === "results" || state.kind === "not-found") { return ( -
-
-
- + + + + Tape Identifier -
+ {state.kind === "results" ? ( <> @@ -92,34 +94,34 @@ export default function TapeIdentifier() { {state.fileName} matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:

{state.matches.map((m) => ( -
-
+ +
-
+ {m.entryTitle} -
+ {m.releaseYear && ( - {m.releaseYear} + {m.releaseYear} )}
{(m.authors.length > 0 || m.genre || m.machinetype) && (
{m.authors.length > 0 && ( - {m.authors.join(", ")} + {m.authors.join(", ")} )} {m.genre && ( - {m.genre} + {m.genre} )} {m.machinetype && ( - {m.machinetype} + {m.machinetype} )}
)} - +
@@ -138,16 +140,16 @@ export default function TapeIdentifier() { -
File{m.crc32}
+
- View entry + View entry - - + + ))} ) : ( @@ -156,23 +158,23 @@ export default function TapeIdentifier() {

)} - - - + + + ); } const isProcessing = state.kind === "hashing" || state.kind === "identifying"; return ( -
-
-
- + + + + Tape Identifier -
+
{isProcessing ? (
-
+ {state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"} @@ -199,7 +201,7 @@ export default function TapeIdentifier() { ) : ( <>
- +

Drop a tape file to identify it @@ -223,11 +225,11 @@ export default function TapeIdentifier() {

{state.kind === "error" && ( -
+ {state.message} -
+ )} -
-
+ + ); } diff --git a/src/app/zxdb/actions.ts b/src/app/zxdb/actions.ts index d8e08fe..4e5b2c2 100644 --- a/src/app/zxdb/actions.ts +++ b/src/app/zxdb/actions.ts @@ -1,6 +1,6 @@ "use server"; -import { lookupByMd5, type TapeMatch } from "@/server/repo/zxdb"; +import { lookupByMd5, type TapeMatch } from "@/server/repo"; export async function identifyTape( md5: string, diff --git a/src/app/zxdb/entries/EntriesExplorer.tsx b/src/app/zxdb/entries/EntriesExplorer.tsx index 86f14f7..c39f7fd 100644 --- a/src/app/zxdb/entries/EntriesExplorer.tsx +++ b/src/app/zxdb/entries/EntriesExplorer.tsx @@ -13,8 +13,9 @@ import FilterSection from "@/components/explorer/FilterSection"; import MultiSelectChips from "@/components/explorer/MultiSelectChips"; import Pagination from "@/components/explorer/Pagination"; import useSearchFetch from "@/hooks/useSearchFetch"; +import type { PagedResult, EntryFacets, EntrySearchScope } from "@/types/zxdb"; +import { preferredMachineIds, parseMachineIds } from "@/utils/params"; -const preferredMachineIds = [27, 26, 8, 9]; const PAGE_SIZE = 20; type Item = { @@ -29,31 +30,6 @@ type Item = { languageName?: string | null; }; -type Paged = { - 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({ initial, initialGenres, @@ -62,7 +38,7 @@ export default function EntriesExplorer({ initialFacets, initialUrlState, }: { - initial?: Paged; + initial?: PagedResult; initialGenres?: { id: number; name: string }[]; initialLanguages?: { id: string; name: string }[]; initialMachines?: { id: number; name: string }[]; @@ -74,7 +50,7 @@ export default function EntriesExplorer({ languageId: string | ""; machinetypeId: string; sort: "title" | "id_desc"; - scope?: SearchScope; + scope?: EntrySearchScope; }; }) { const router = useRouter(); @@ -90,7 +66,7 @@ export default function EntriesExplorer({ const [languageId, setLanguageId] = useState(initialUrlState?.languageId ?? ""); const [machinetypeIds, setMachinetypeIds] = useState(parseMachineIds(initialUrlState?.machinetypeId)); const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc"); - const [scope, setScope] = useState(initialUrlState?.scope ?? "title"); + const [scope, setScope] = useState(initialUrlState?.scope ?? "title"); const [facets, setFacets] = useState(initialFacets ?? null); // -- Filter lists -- @@ -314,7 +290,7 @@ export default function EntriesExplorer({ - { setScope(e.target.value as SearchScope); setPage(1); }}> + { setScope(e.target.value as EntrySearchScope); setPage(1); }}> diff --git a/src/app/zxdb/entries/[id]/loading.tsx b/src/app/zxdb/entries/[id]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/entries/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/entries/[id]/page.tsx b/src/app/zxdb/entries/[id]/page.tsx index b05e51b..a044e18 100644 --- a/src/app/zxdb/entries/[id]/page.tsx +++ b/src/app/zxdb/entries/[id]/page.tsx @@ -1,16 +1,20 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import EntryDetailClient from "./EntryDetail"; -import { getEntryById } from "@/server/repo/zxdb"; - -export const metadata = { - title: "ZXDB Entry", -}; +import { getEntryById } from "@/server/repo"; export const revalidate = 3600; +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + 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 }> }) { const { id } = await params; - const numericId = Number(id); - const data = await getEntryById(numericId); - // For simplicity, let the client render a Not Found state if null + const data = await getEntryById(Number(id)); + if (!data) notFound(); return ; } diff --git a/src/app/zxdb/entries/page.tsx b/src/app/zxdb/entries/page.tsx index effccd5..02f9db7 100644 --- a/src/app/zxdb/entries/page.tsx +++ b/src/app/zxdb/entries/page.tsx @@ -1,5 +1,6 @@ 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 = { title: "ZXDB Entries", @@ -7,16 +8,6 @@ export const metadata = { 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 }> }) { const sp = await searchParams; const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); diff --git a/src/app/zxdb/genres/GenresSearch.tsx b/src/app/zxdb/genres/GenresSearch.tsx index 50e338d..c44b006 100644 --- a/src/app/zxdb/genres/GenresSearch.tsx +++ b/src/app/zxdb/genres/GenresSearch.tsx @@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; 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 Pagination from "@/components/explorer/Pagination"; -type Genre = { id: number; name: string }; -type Paged = { items: T[]; page: number; pageSize: number; total: number }; +import type { PagedResult } from "@/types/zxdb"; -export default function GenresSearch({ initial, initialQ }: { initial?: Paged; initialQ?: string }) { +type Genre = { id: number; name: string }; + +export default function GenresSearch({ initial, initialQ }: { initial?: PagedResult; initialQ?: string }) { const router = useRouter(); const [q, setQ] = useState(initialQ ?? ""); - const [data, setData] = useState | null>(initial ?? null); + const [data, setData] = useState | null>(initial ?? null); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); useEffect(() => { @@ -54,49 +56,48 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged
-
-
-
-
- -
- - setQ(e.target.value)} /> -
+ + + + + + + Search + setQ(e.target.value)} /> +
- +
- -
-
-
+ + + + -
- {data && data.items.length === 0 &&
No genres found.
} + + {data && data.items.length === 0 && No genres found.} {data && data.items.length > 0 && ( -
- - - - - +
IDName
+ + + + + + + + + {data.items.map((g) => ( + + + - - - {data.items.map((g) => ( - - - - - ))} - -
Genres search results
IDName
#{g.id} + {g.name} +
#{g.id} - {g.name} -
-
+ ))} + + )} -
-
+ + = { items: T[]; page: number; pageSize: number; total: number }; +import type { PagedResult } from "@/types/zxdb"; -export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: Paged; 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; initialQ?: string }) { const router = useRouter(); const [q, setQ] = useState(initialQ ?? ""); 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

Genre #{id}

{ e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}>
- setQ(e.target.value)} /> + setQ(e.target.value)} />
diff --git a/src/app/zxdb/genres/[id]/loading.tsx b/src/app/zxdb/genres/[id]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/genres/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/genres/[id]/page.tsx b/src/app/zxdb/genres/[id]/page.tsx index f9c83f1..cde126f 100644 --- a/src/app/zxdb/genres/[id]/page.tsx +++ b/src/app/zxdb/genres/[id]/page.tsx @@ -1,5 +1,5 @@ import GenreDetailClient from "./GenreDetail"; -import { entriesByGenre } from "@/server/repo/zxdb"; +import { entriesByGenre } from "@/server/repo"; export const metadata = { title: "ZXDB Genre" }; diff --git a/src/app/zxdb/genres/page.tsx b/src/app/zxdb/genres/page.tsx index 880272d..7343c82 100644 --- a/src/app/zxdb/genres/page.tsx +++ b/src/app/zxdb/genres/page.tsx @@ -1,5 +1,5 @@ import GenresSearch from "./GenresSearch"; -import { searchGenres } from "@/server/repo/zxdb"; +import { searchGenres } from "@/server/repo"; export const metadata = { title: "ZXDB Genres" }; diff --git a/src/app/zxdb/issues/[id]/loading.tsx b/src/app/zxdb/issues/[id]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/issues/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/issues/[id]/page.tsx b/src/app/zxdb/issues/[id]/page.tsx index 1e49578..1d58cb5 100644 --- a/src/app/zxdb/issues/[id]/page.tsx +++ b/src/app/zxdb/issues/[id]/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { getIssue } from "@/server/repo/zxdb"; +import { getIssue } from "@/server/repo"; import EntryLink from "@/app/zxdb/components/EntryLink"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; @@ -29,7 +29,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }> />
- ← Back to magazine + ← Back to magazine All magazines {issue.linkMask && ( Issue link @@ -57,6 +57,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }> ) : (
+ @@ -75,7 +76,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }> ) : r.labelId ? ( {r.labelName ?? r.labelId} ) : ( - β€” + )} diff --git a/src/app/zxdb/labels/LabelsSearch.tsx b/src/app/zxdb/labels/LabelsSearch.tsx index 39d6ca6..99d2444 100644 --- a/src/app/zxdb/labels/LabelsSearch.tsx +++ b/src/app/zxdb/labels/LabelsSearch.tsx @@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; 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 Pagination from "@/components/explorer/Pagination"; -type Label = { id: number; name: string; labeltypeId: string | null }; -type Paged = { items: T[]; page: number; pageSize: number; total: number }; +import type { PagedResult } from "@/types/zxdb"; -export default function LabelsSearch({ initial, initialQ }: { initial?: Paged+ + + + + Search + setQ(e.target.value)} /> +
- +
- - - - + +
+
+ -
- {data && data.items.length === 0 &&
No labels found.
} +
+ {data && data.items.length === 0 && No labels found.} {data && data.items.length > 0 && ( -
-
Issue references
Page
- - - - - +
IDNameType
+ + + + + + + + + + {data.items.map((l) => ( + + + + - - - {data.items.map((l) => ( - - - - - - ))} - -
Labels search results
IDNameType
#{l.id} + {l.name} + + {l.labeltypeId ?? "?"} +
#{l.id} - {l.name} - - {l.labeltypeId ?? "?"} -
-
+ ))} + + )} -
-
+ + = { items: T[]; page: number; pageSize: number; total: number }; +import type { PagedResult } from "@/types/zxdb"; -type Payload = { label: Label | null; authored: Paged; published: Paged }; +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; published: PagedResult }; 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. @@ -43,24 +45,24 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }: // Names are now delivered by SSR payload to minimize pop-in. // Hooks must be called unconditionally - const current = useMemo | null>( + const current = useMemo | null>( () => (tab === "authored" ? initial?.authored : initial?.published) ?? null, [initial, tab] ); const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]); - if (!initial || !initial.label) return
Not found
; + if (!initial || !initial.label) return Not found; return (

{initial.label.name}

- + {initial.label.labeltypeName ? `${initial.label.labeltypeName} (${initial.label.labeltypeId ?? "?"})` : (initial.label.labeltypeId ?? "?")} - +
{(initial.label.countryId || initial.label.linkWikipedia || initial.label.linkSite) && ( @@ -82,140 +84,130 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
)} -
-
+ +
Permissions
{initial.label.permissions.length === 0 &&
No permissions recorded
} {initial.label.permissions.length > 0 && ( -
- - - - - - - - - - {initial.label.permissions.map((p, idx) => ( - - - - - - ))} - -
WebsiteTypeNotes
- {p.website.link ? ( - {p.website.name} - ) : ( - {p.website.name} - )} - {p.type.name ?? p.type.id}{p.text ?? ""}
-
- )} -
-
-
Licenses
- {initial.label.licenses.length === 0 &&
No licenses linked
} - {initial.label.licenses.length > 0 && ( -
- - - - - - - - - - {initial.label.licenses.map((l) => ( - - - - - - ))} - -
NameTypeLinks
{l.name}{l.type.name ?? l.type.id} -
- {l.linkWikipedia && ( - Wikipedia - )} - {l.linkSite && ( - Site - )} - {!l.linkWikipedia && !l.linkSite && -} -
-
-
- )} -
-
- -
    -
  • - -
  • -
  • - -
  • -
- -
{ 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()}`); }}> -
- setQ(e.target.value)} /> -
-
- -
-
- -
- {current && current.items.length === 0 &&
No entries.
} - {current && current.items.length > 0 && ( -
- +
- - - - + + + - {current.items.map((it) => ( - - - + {initial.label.permissions.map((p, idx) => ( + + + + + ))} + +
IDTitleMachineLanguageWebsiteTypeNotes
- {it.machinetypeId != null ? ( - it.machinetypeName ? ( - {it.machinetypeName} - ) : ( - {it.machinetypeId} - ) + {p.website.link ? ( + {p.website.name} ) : ( - - + {p.website.name} )} {p.type.name ?? p.type.id}{p.text ?? ""}
+ )} + + +
Licenses
+ {initial.label.licenses.length === 0 &&
No licenses linked
} + {initial.label.licenses.length > 0 && ( + + + + + + + + + + {initial.label.licenses.map((l) => ( + + + ))} -
NameTypeLinks
{l.name}{l.type.name ?? l.type.id} - {it.languageId ? ( - it.languageName ? ( - {it.languageName} - ) : ( - {it.languageId} - ) - ) : ( - - - )} +
+ {l.linkWikipedia && ( + Wikipedia + )} + {l.linkSite && ( + Site + )} + {!l.linkWikipedia && !l.linkSite && -} +
-
+ + )} + + + + + +
{ 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()}`); }}> + setQ(e.target.value)} /> + + + +
+ {current && current.items.length === 0 && No entries.} + {current && current.items.length > 0 && ( + + + + + + + + + + + {current.items.map((it) => ( + + + + + + + ))} + +
IDTitleMachineLanguage
+ {it.machinetypeId != null ? ( + it.machinetypeName ? ( + {it.machinetypeName} + ) : ( + {it.machinetypeId} + ) + ) : ( + - + )} + + {it.languageId ? ( + it.languageName ? ( + {it.languageName} + ) : ( + {it.languageId} + ) + ) : ( + - + )} +
)}
diff --git a/src/app/zxdb/labels/[id]/loading.tsx b/src/app/zxdb/labels/[id]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/labels/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/labels/[id]/page.tsx b/src/app/zxdb/labels/[id]/page.tsx index 168cfca..2bf9082 100644 --- a/src/app/zxdb/labels/[id]/page.tsx +++ b/src/app/zxdb/labels/[id]/page.tsx @@ -1,7 +1,13 @@ +import type { Metadata } from "next"; 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 { + 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. export const dynamic = "force-dynamic"; diff --git a/src/app/zxdb/labels/page.tsx b/src/app/zxdb/labels/page.tsx index 6dc18fd..81bbbd4 100644 --- a/src/app/zxdb/labels/page.tsx +++ b/src/app/zxdb/labels/page.tsx @@ -1,5 +1,5 @@ import LabelsSearch from "./LabelsSearch"; -import { searchLabels } from "@/server/repo/zxdb"; +import { searchLabels } from "@/server/repo"; export const metadata = { title: "ZXDB Labels" }; diff --git a/src/app/zxdb/languages/LanguagesSearch.tsx b/src/app/zxdb/languages/LanguagesSearch.tsx index e52af83..9742b00 100644 --- a/src/app/zxdb/languages/LanguagesSearch.tsx +++ b/src/app/zxdb/languages/LanguagesSearch.tsx @@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; 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 Pagination from "@/components/explorer/Pagination"; -type Language = { id: string; name: string }; -type Paged = { items: T[]; page: number; pageSize: number; total: number }; +import type { PagedResult } from "@/types/zxdb"; -export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged; initialQ?: string }) { +type Language = { id: string; name: string }; + +export default function LanguagesSearch({ initial, initialQ }: { initial?: PagedResult; initialQ?: string }) { const router = useRouter(); const [q, setQ] = useState(initialQ ?? ""); - const [data, setData] = useState | null>(initial ?? null); + const [data, setData] = useState | null>(initial ?? null); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); useEffect(() => { @@ -54,49 +56,48 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
-
-
-
-
-
-
- - setQ(e.target.value)} /> -
+ + + + + + + Search + setQ(e.target.value)} /> +
- +
- -
-
-
+ + + + -
- {data && data.items.length === 0 &&
No languages found.
} + + {data && data.items.length === 0 && No languages found.} {data && data.items.length > 0 && ( -
- - - - - +
CodeName
+ + + + + + + + + {data.items.map((l) => ( + + + - - - {data.items.map((l) => ( - - - - - ))} - -
Languages search results
CodeName
{l.id} + {l.name} +
{l.id} - {l.name} -
-
+ ))} + + )} -
-
+ + = { items: T[]; page: number; pageSize: number; total: number }; +import type { PagedResult } from "@/types/zxdb"; -export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: Paged; 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; initialQ?: string }) { const router = useRouter(); const [q, setQ] = useState(initialQ ?? ""); 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

Language {id}

{ e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}>
- setQ(e.target.value)} /> + setQ(e.target.value)} />
diff --git a/src/app/zxdb/languages/[id]/loading.tsx b/src/app/zxdb/languages/[id]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/languages/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/languages/[id]/page.tsx b/src/app/zxdb/languages/[id]/page.tsx index 93585f9..67ed4ec 100644 --- a/src/app/zxdb/languages/[id]/page.tsx +++ b/src/app/zxdb/languages/[id]/page.tsx @@ -1,5 +1,5 @@ import LanguageDetailClient from "./LanguageDetail"; -import { entriesByLanguage } from "@/server/repo/zxdb"; +import { entriesByLanguage } from "@/server/repo"; export const metadata = { title: "ZXDB Language" }; diff --git a/src/app/zxdb/languages/page.tsx b/src/app/zxdb/languages/page.tsx index 432e3a2..237ea90 100644 --- a/src/app/zxdb/languages/page.tsx +++ b/src/app/zxdb/languages/page.tsx @@ -1,5 +1,5 @@ import LanguagesSearch from "./LanguagesSearch"; -import { searchLanguages } from "@/server/repo/zxdb"; +import { searchLanguages } from "@/server/repo"; export const metadata = { title: "ZXDB Languages" }; diff --git a/src/app/zxdb/machinetypes/MachineTypesSearch.tsx b/src/app/zxdb/machinetypes/MachineTypesSearch.tsx index 6863cce..eb0b592 100644 --- a/src/app/zxdb/machinetypes/MachineTypesSearch.tsx +++ b/src/app/zxdb/machinetypes/MachineTypesSearch.tsx @@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; 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 Pagination from "@/components/explorer/Pagination"; -type MT = { id: number; name: string }; -type Paged = { items: T[]; page: number; pageSize: number; total: number }; +import type { PagedResult } from "@/types/zxdb"; -export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged; initialQ?: string }) { +type MT = { id: number; name: string }; + +export default function MachineTypesSearch({ initial, initialQ }: { initial?: PagedResult; initialQ?: string }) { const router = useRouter(); const [q, setQ] = useState(initialQ ?? ""); - const [data, setData] = useState | null>(initial ?? null); + const [data, setData] = useState | null>(initial ?? null); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); useEffect(() => { @@ -54,49 +56,48 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
-
-
-
-
- -
- - setQ(e.target.value)} /> -
+ + + + + + + Search + setQ(e.target.value)} /> +
- +
- -
-
-
+ + + + -
- {data && data.items.length === 0 &&
No machine types found.
} + + {data && data.items.length === 0 && No machine types found.} {data && data.items.length > 0 && ( -
- - - - - +
IDName
+ + + + + + + + + {data.items.map((m) => ( + + + - - - {data.items.map((m) => ( - - - - - ))} - -
Machine types search results
IDName
#{m.id} + {m.name} +
#{m.id} - {m.name} -
-
+ ))} + + )} -
-
+ + = { 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; initialQ?: string }) { +export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: PagedResult; initialQ?: string }) { const router = useRouter(); const [q, setQ] = useState(initialQ ?? ""); 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:

{machineName ?? "Machine Type"} #{id}

{ e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}>
- setQ(e.target.value)} /> + setQ(e.target.value)} />
diff --git a/src/app/zxdb/machinetypes/[id]/loading.tsx b/src/app/zxdb/machinetypes/[id]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/machinetypes/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/machinetypes/[id]/page.tsx b/src/app/zxdb/machinetypes/[id]/page.tsx index 449d3fc..e5a3bab 100644 --- a/src/app/zxdb/machinetypes/[id]/page.tsx +++ b/src/app/zxdb/machinetypes/[id]/page.tsx @@ -1,5 +1,5 @@ import MachineTypeDetailClient from "./MachineTypeDetail"; -import { entriesByMachinetype } from "@/server/repo/zxdb"; +import { entriesByMachinetype } from "@/server/repo"; export const metadata = { title: "ZXDB Machine Type" }; // Depends on searchParams (?page=). Force dynamic so each page renders correctly. diff --git a/src/app/zxdb/machinetypes/page.tsx b/src/app/zxdb/machinetypes/page.tsx index d2348a5..a4c1319 100644 --- a/src/app/zxdb/machinetypes/page.tsx +++ b/src/app/zxdb/machinetypes/page.tsx @@ -1,5 +1,5 @@ import MachineTypesSearch from "./MachineTypesSearch"; -import { searchMachinetypes } from "@/server/repo/zxdb"; +import { searchMachinetypes } from "@/server/repo"; export const metadata = { title: "ZXDB Machine Types" }; diff --git a/src/app/zxdb/magazines/[id]/loading.tsx b/src/app/zxdb/magazines/[id]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/magazines/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/magazines/[id]/page.tsx b/src/app/zxdb/magazines/[id]/page.tsx index 5c6f512..d58eb96 100644 --- a/src/app/zxdb/magazines/[id]/page.tsx +++ b/src/app/zxdb/magazines/[id]/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; 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"; export const metadata = { title: "ZXDB Magazine" }; @@ -28,7 +29,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
Language: {mag.languageId}
- ← Back to list + ← Back to list {mag.linkSite && ( Official site @@ -42,6 +43,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }> ) : (
+ @@ -71,13 +73,13 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
{i.linkMask && ( - + Link )} {i.archiveMask && ( - + Archive )} diff --git a/src/app/zxdb/magazines/page.tsx b/src/app/zxdb/magazines/page.tsx index ce2e97b..8c938bc 100644 --- a/src/app/zxdb/magazines/page.tsx +++ b/src/app/zxdb/magazines/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { listMagazines } from "@/server/repo/zxdb"; +import { listMagazines } from "@/server/repo"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; 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 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 (
@@ -54,6 +62,7 @@ export default async function Page({
Magazine issues
Issue
+ @@ -81,37 +90,21 @@ export default async function Page({ - + {totalPages > 1 && ( + + )} ); } - -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 ( - - ); -} diff --git a/src/app/zxdb/page.tsx b/src/app/zxdb/page.tsx index a3cfaf9..cf5a14a 100644 --- a/src/app/zxdb/page.tsx +++ b/src/app/zxdb/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { Collection, BoxArrowDown, JournalText, People } from "react-bootstrap-icons"; import TapeIdentifier from "./TapeIdentifier"; export const metadata = { @@ -19,8 +20,8 @@ export default async function Page() {
- ZXDB - Explorer + ZXDB + Explorer

ZXDB Explorer

@@ -30,14 +31,14 @@ export default async function Page() { Browse entries Latest releases Magazine issues - People & labels + People & labels

Jump straight in
- +
@@ -50,7 +51,7 @@ export default async function Page() {
- +
@@ -58,15 +59,17 @@ export default async function Page() {
-
-
- -
-
-

- Drop a .tap, .tzx, or other tape file to identify it against 32,000+ ZXDB entries. - The file stays in your browser — only its hash is sent. -

+
+
+
+ +
+
+

+ Drop a .tap, .tzx, or other tape file to identify it against 32,000+ ZXDB entries. + The file stays in your browser — only its hash is sent. +

+
@@ -81,10 +84,10 @@ export default async function Page() {
- +
Entries
-
Search + filter titles
+

Search + filter titles

@@ -96,10 +99,10 @@ export default async function Page() {
- +
Releases
-
Downloads + media
+

Downloads + media

@@ -111,10 +114,10 @@ export default async function Page() {
- +
Magazines
-
Issues + references
+

Issues + references

@@ -126,10 +129,10 @@ export default async function Page() {
- +
Labels
-
People + publishers
+

People + publishers

@@ -139,31 +142,33 @@ export default async function Page() {
-
-
-
-
-

Explore by category

-

Jump to curated lists and filter results from there.

-
- Genres - Languages - Machine Types - Labels +
+
+
+
+
+

Explore by category

+

Jump to curated lists and filter results from there.

+
+ Genres + Languages + Machine Types + Labels +
-
-
-
-
-

How to use this

-
    -
  1. Search by title or aliases in Entries.
  2. -
  3. Open a release to see downloads, scraps, and places.
  4. -
  5. Use magazines to find original reviews and references.
  6. -
  7. Follow labels to discover related work.
  8. -
+
+
+
+

How to use this

+
    +
  1. Search by title or aliases in Entries.
  2. +
  3. Open a release to see downloads, scraps, and places.
  4. +
  5. Use magazines to find original reviews and references.
  6. +
  7. Follow labels to discover related work.
  8. +
+
diff --git a/src/app/zxdb/releases/ReleasesExplorer.tsx b/src/app/zxdb/releases/ReleasesExplorer.tsx index f660d87..8731b51 100644 --- a/src/app/zxdb/releases/ReleasesExplorer.tsx +++ b/src/app/zxdb/releases/ReleasesExplorer.tsx @@ -13,19 +13,11 @@ import FilterSection from "@/components/explorer/FilterSection"; import MultiSelectChips from "@/components/explorer/MultiSelectChips"; import Pagination from "@/components/explorer/Pagination"; import useSearchFetch from "@/hooks/useSearchFetch"; +import type { PagedResult } from "@/types/zxdb"; +import { preferredMachineIds, parseMachineIds } from "@/utils/params"; -const preferredMachineIds = [27, 26, 8, 9]; const PAGE_SIZE = 20; -function parseMachineIds(value?: string) { - if (!value) return preferredMachineIds.slice(); - const ids = value - .split(",") - .map((id) => Number(id.trim())) - .filter((id) => Number.isFinite(id) && id > 0); - return ids.length ? ids : preferredMachineIds.slice(); -} - type Item = { entryId: number; releaseSeq: number; @@ -34,19 +26,12 @@ type Item = { magrefCount: number; }; -type Paged = { - items: T[]; - page: number; - pageSize: number; - total: number; -}; - export default function ReleasesExplorer({ initial, initialUrlState, initialLists, }: { - initial?: Paged; + initial?: PagedResult; initialUrlState?: { q: string; page: number; diff --git a/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx index 352d9fe..96541d1 100644 --- a/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx +++ b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from "react"; import Link from "next/link"; +import { Row, Col, Card, Table, Badge, Alert, Button } from "react-bootstrap"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; 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])); }, [data?.scraps]); - if (!data) return
Not found
; + if (!data) return Not found; const magazineGroups = groupMagazineRefs(data.magazineRefs); const otherReleases = data.entryReleases.filter((r) => r.releaseSeq !== data.release.releaseSeq); @@ -216,18 +217,17 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData

Release #{data.release.releaseSeq}

- - {data.entry.title} + + {data.entry.title}
-
-
-
-
-
Release Summary
-
-
Magazines search results
Title
+ + + + + Release Summary +
@@ -292,36 +292,35 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData -
Entry{data.release.book.pages ?? -}
-
-
-
+ + + -
-
-
Other Releases
+ + + Other Releases {otherReleases.length === 0 &&
No other releases
} {otherReleases.length > 0 && (
{otherReleases.map((r) => ( - #{r.releaseSeq}{r.year != null ? ` Β· ${r.year}` : ""} + #{r.releaseSeq}{r.year != null ? ` Β· ${r.year}` : ""} ))}
)} -
-
- + + + -
-
-
-
Places (Magazines)
+ + + + Places (Magazines) {magazineGroups.length === 0 &&
No magazine references
} {magazineGroups.length > 0 && (
@@ -350,8 +349,7 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData {issueGroup.items.length} reference{issueGroup.items.length === 1 ? "" : "s"}
-
- +
@@ -370,26 +368,24 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData ))} -
Page
-
+
))}
))} )} - - + + -
-
-
Downloads
+ + + Downloads {groupedDownloads.length === 0 &&
No downloads
} {groupedDownloads.map(([type, items]) => (
{type}
-
- +
@@ -458,22 +454,20 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData ); })} -
Link
-
+
))} -
-
+ + -
-
-
Scraps / Media
+ + + Scraps / Media {groupedScraps.length === 0 &&
No scraps
} {groupedScraps.map(([type, items]) => (
{type}
-
- +
@@ -544,56 +538,53 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData ); })} -
Link
-
+
))} -
-
+ + -
-
-
Issue Files
+ + + Issue Files {data.files.length === 0 &&
No files linked
} {data.files.length > 0 && ( -
- - - - - - - - - - - - {data.files.map((f) => { - const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://"); - return ( - - - - - - - - ); - })} - -
TypeLinkSizeMD5Comments
{f.type.name} - {isHttp ? ( - {f.link} - ) : ( - {f.link} - )} - {f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}{f.md5 ?? "-"}{f.comments ?? ""}
-
+ + + + + + + + + + + + {data.files.map((f) => { + const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://"); + return ( + + + + + + + + ); + })} + +
TypeLinkSizeMD5Comments
{f.type.name} + {isHttp ? ( + {f.link} + ) : ( + {f.link} + )} + {f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}{f.md5 ?? "-"}{f.comments ?? ""}
)} -
-
- - + + + +
Permalink diff --git a/src/app/zxdb/releases/[entryId]/[releaseSeq]/loading.tsx b/src/app/zxdb/releases/[entryId]/[releaseSeq]/loading.tsx new file mode 100644 index 0000000..c89899e --- /dev/null +++ b/src/app/zxdb/releases/[entryId]/[releaseSeq]/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "react-bootstrap"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx b/src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx index 4c11778..0b66367 100644 --- a/src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx +++ b/src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx @@ -1,16 +1,20 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import ReleaseDetailClient from "./ReleaseDetail"; -import { getReleaseDetail } from "@/server/repo/zxdb"; - -export const metadata = { - title: "ZXDB Release", -}; +import { getReleaseDetail } from "@/server/repo"; export const revalidate = 3600; +export async function generateMetadata({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }): Promise { + 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 }> }) { const { entryId, releaseSeq } = await params; - const entryIdNum = Number(entryId); - const releaseSeqNum = Number(releaseSeq); - const data = await getReleaseDetail(entryIdNum, releaseSeqNum); + const data = await getReleaseDetail(Number(entryId), Number(releaseSeq)); + if (!data) notFound(); return ; } diff --git a/src/app/zxdb/releases/page.tsx b/src/app/zxdb/releases/page.tsx index aeed10a..1001f9a 100644 --- a/src/app/zxdb/releases/page.tsx +++ b/src/app/zxdb/releases/page.tsx @@ -1,5 +1,7 @@ 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 = { title: "ZXDB Releases", @@ -7,16 +9,6 @@ export const metadata = { 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 }> }) { const sp = await searchParams; const hasParams = Object.values(sp).some((value) => value !== undefined); @@ -47,20 +39,17 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [ listCasetypes(), ]); - // Ensure the object passed to a Client Component is a plain JSON value - const initialPlain = JSON.parse(JSON.stringify(initial)); - return ( diff --git a/src/components/FileViewer.tsx b/src/components/FileViewer.tsx index ccf3b4f..b58b414 100644 --- a/src/components/FileViewer.tsx +++ b/src/components/FileViewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Modal, Button, Spinner } from "react-bootstrap"; type FileViewerProps = { @@ -20,8 +20,10 @@ export default function FileViewer({ url, title, onClose }: FileViewerProps) { const viewUrl = url.includes("?") ? `${url}&view=1` : `${url}?view=1`; - useState(() => { + useEffect(() => { if (isText) { + setLoading(true); + setError(null); fetch(viewUrl) .then((res) => { if (!res.ok) throw new Error("Failed to load file"); @@ -38,7 +40,7 @@ export default function FileViewer({ url, title, onClose }: FileViewerProps) { } else { setLoading(false); } - }); + }, [viewUrl, isText]); return ( diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ee4dcf5..ab1558b 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -8,13 +8,13 @@ export default function NavbarClient() { return ( - SpecNext Explorer + SpecNext Explorer diff --git a/src/components/ThemeDropdown.tsx b/src/components/ThemeDropdown.tsx index 3b7f714..58af0c8 100644 --- a/src/components/ThemeDropdown.tsx +++ b/src/components/ThemeDropdown.tsx @@ -1,7 +1,7 @@ "use client"; 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"; type Theme = "light" | "dark" | "auto"; @@ -13,7 +13,7 @@ const getCookie = (name: 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 = () => @@ -62,12 +62,12 @@ export default function ThemeDropdown() { const isActive = (t: Theme) => theme === t; const ToggleIcon = !mounted - ? Icon.CircleHalf + ? CircleHalf : theme === "dark" - ? Icon.MoonStarsFill + ? MoonStarsFill : theme === "light" - ? Icon.SunFill - : Icon.CircleHalf; + ? SunFill + : CircleHalf; return (