feat: integrate ZXDB with Drizzle + deep explorer UI; fix Next 15 dynamic params; align ZXDB schema columns
End-to-end ZXDB integration with environment validation, Drizzle ORM MySQL
setup, typed repositories, Zod-validated API endpoints, and a deep, cross‑
linked Explorer UI under `/zxdb`. Also update dynamic route pages to the
Next.js 15 async `params` API and align ZXDB lookup table columns (`text` vs
`name`).
Summary
- Add t3.gg-style Zod environment validation and typed `env` access
- Wire Drizzle ORM to ZXDB (mysql2 pool, singleton) and minimal schemas
- Implement repositories for search, entry details, label browsing, and
category listings (genres, languages, machinetypes)
- Expose a set of Next.js API routes with strict Zod validation
- Build the ZXDB Explorer UI with search, filters, sorting, deep links, and
entity pages (entries, labels, genres, languages, machinetypes)
- Fix Next 15 “sync-dynamic-apis” warning by awaiting dynamic `params`
- Correct ZXDB lookup model columns to use `text` (aliased as `name`)
Details
Env & DB
- example.env: document `ZXDB_URL` with readonly role notes
- src/env.ts: Zod schema validates `ZXDB_URL` as `mysql://…`; fails fast on
invalid env
- src/server/db.ts: create mysql2 pool from `ZXDB_URL`; export Drizzle instance
- drizzle.config.ts: drizzle-kit configuration (schema path, mysql2 driver)
Schema (Drizzle)
- src/server/schema/zxdb.ts:
- entries: id, title, is_xrated, machinetype_id, language_id, genretype_id
- helper tables: search_by_titles, search_by_names, search_by_authors,
search_by_publishers
- relations: authors, publishers
- lookups: labels, languages, machinetypes, genretypes
- map lookup display columns from DB `text` to model property `name`
Repository
- src/server/repo/zxdb.ts:
- searchEntries: title search via helper table with filters (genre, language,
machine), sorting (title, id_desc), and pagination
- getEntryById: join lookups and aggregate authors/publishers
- Label flows: searchLabels (helper table), getLabelById, getLabelAuthoredEntries,
getLabelPublishedEntries
- Category lists: listGenres, listLanguages, listMachinetypes
- Category pages: entriesByGenre, entriesByLanguage, entriesByMachinetype
API (Node runtime, Zod validation)
- GET /api/zxdb/search: search entries with filters and sorting
- GET /api/zxdb/entries/[id]: fetch entry detail
- GET /api/zxdb/labels/search, GET /api/zxdb/labels/[id]: label search and detail
- GET /api/zxdb/genres, /api/zxdb/genres/[id]
- GET /api/zxdb/languages, /api/zxdb/languages/[id]
- GET /api/zxdb/machinetypes, /api/zxdb/machinetypes/[id]
UI (App Router)
- /zxdb: Explorer page with search box, filters (genre, language, machine), sort,
paginated results & links to entries; quick browse links to hubs
- /zxdb/entries/[id]: entry detail client component shows title, badges
(genre/lang/machine), authors and publishers with cross-links
- /zxdb/labels (+ /[id]): search & label detail with "Authored" and "Published"
tabs, paginated lists linking to entries
- /zxdb/genres, /zxdb/languages, /zxdb/machinetypes and their /[id] detail pages
listing paginated entries and deep links
- Navbar: add ZXDB link
Next 15 dynamic routes
- Convert Server Component dynamic pages to await `params` before accessing
properties:
- /zxdb/entries/[id]/page.tsx
- /zxdb/labels/[id]/page.tsx
- /zxdb/genres/[id]/page.tsx
- /zxdb/languages/[id]/page.tsx
- /registers/[hex]/page.tsx (Registers section)
- /api/zxdb/entries/[id]/route.ts: await `ctx.params` before validation
ZXDB schema column alignment
- languages, machinetypes, genretypes tables use `text` for display columns;
models now map to `name` to preserve API/UI contracts and avoid MySQL 1054
errors in joins (e.g., entry detail endpoint).
Notes
- Ensure ZXDB helper tables are created (ZXDB/scripts/ZXDB_help_search.sql)
— required for fast title/name searches and author/publisher lookups.
- Pagination defaults to 20 (max 100). No `select *` used in queries.
- API responses are `cache: no-store` for now; can be tuned later.
Deferred (future work)
- Facet counts in the Explorer sidebar
- Breadcrumbs and additional a11y polish
- Media assets and download links per release
Signed-off-by: Junie@lucy.xalior.com
Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
@@ -114,7 +114,8 @@ Comment what the code does, not what the agent has done. The documentation's pur
|
||||
- git branching:
|
||||
- Do not create new branches
|
||||
- git commits:
|
||||
- Do not commit code - just create COMMIT_EDITMSG file for manual commiting.
|
||||
- Create COMMIT_EDITMSG file, await any user edits, then commit using that
|
||||
commit note, and then delete the COMMIT_EDITMSG file.
|
||||
- git commit messages:
|
||||
- Use imperative mood (e.g., "Add feature X", "Fix bug Y").
|
||||
- Include relevant issue numbers if applicable.
|
||||
|
||||
32
COMMIT_EDITMSG
Normal file
32
COMMIT_EDITMSG
Normal file
@@ -0,0 +1,32 @@
|
||||
fix: await dynamic route params (Next 15) and correct ZXDB lookup column names
|
||||
|
||||
Update dynamic Server Component pages to the Next.js 15+ async `params` API,
|
||||
and fix ZXDB lookup table schema to use `text` column (not `name`) to avoid
|
||||
ER_BAD_FIELD_ERROR in entry detail endpoint.
|
||||
This resolves the runtime warning/error:
|
||||
"params should be awaited before using its properties" and prevents
|
||||
sync-dynamic-apis violations when visiting deep ZXDB permalinks.
|
||||
|
||||
Changes
|
||||
- /zxdb/entries/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
||||
- /zxdb/labels/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
||||
- /zxdb/genres/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
||||
- /zxdb/languages/[id]/page.tsx: make Page async and `await params`, pass string id
|
||||
- /registers/[hex]/page.tsx: make Page async and `await params`, decode hex safely
|
||||
- /api/zxdb/entries/[id]/route.ts: await `ctx.params` before validation
|
||||
- src/server/schema/zxdb.ts: map `languages.text`, `machinetypes.text`,
|
||||
and `genretypes.text` to `name` fields in Drizzle models
|
||||
|
||||
Why
|
||||
- Next.js 15 changed dynamic route APIs such that `params` is now a Promise
|
||||
in Server Components and must be awaited before property access.
|
||||
- ZXDB schema defines display columns as `text` (not `name`) for languages,
|
||||
machinetypes, and genretypes. Using `name` caused MySQL 1054 errors. The
|
||||
Drizzle models now point to the correct columns while preserving `{ id, name }`
|
||||
in our API/UI contracts.
|
||||
|
||||
Notes
|
||||
- API route handlers under /api continue to use ctx.params synchronously; this
|
||||
change only affects App Router Page components.
|
||||
|
||||
Signed-off-by: Junie@lucy.xalior.com
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
"drizzle-orm": "^0.36.1",
|
||||
"mysql2": "^3.12.0",
|
||||
"next": "~15.5.7",
|
||||
"react": "19.1.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-bootstrap-icons": "^1.11.6",
|
||||
"react-dom": "19.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"drizzle-orm": "^0.36.1",
|
||||
"mysql2": "^3.12.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,9 +27,9 @@
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "15.5.4",
|
||||
"sass": "^1.94.2",
|
||||
"drizzle-kit": "^0.30.1"
|
||||
"sass": "^1.63.6"
|
||||
}
|
||||
}
|
||||
|
||||
246
pnpm-lock.yaml
generated
246
pnpm-lock.yaml
generated
@@ -19,7 +19,7 @@ importers:
|
||||
version: 3.15.3
|
||||
next:
|
||||
specifier: ~15.5.7
|
||||
version: 15.5.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.94.2)
|
||||
version: 15.5.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.63.6)
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -61,8 +61,8 @@ importers:
|
||||
specifier: 15.5.4
|
||||
version: 15.5.4(eslint@9.39.1)(typescript@5.9.3)
|
||||
sass:
|
||||
specifier: ^1.94.2
|
||||
version: 1.94.2
|
||||
specifier: ^1.63.6
|
||||
version: 1.63.6
|
||||
|
||||
packages:
|
||||
|
||||
@@ -624,88 +624,6 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@petamoriken/float16@3.9.3':
|
||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||
|
||||
@@ -951,6 +869,10 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -1016,6 +938,10 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bootstrap@5.3.8:
|
||||
resolution: {integrity: sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==}
|
||||
peerDependencies:
|
||||
@@ -1057,9 +983,9 @@ packages:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
@@ -1135,11 +1061,6 @@ packages:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1473,6 +1394,11 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
@@ -1572,8 +1498,8 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immutable@5.1.4:
|
||||
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
|
||||
immutable@4.3.7:
|
||||
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
@@ -1602,6 +1528,10 @@ packages:
|
||||
resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-boolean-object@1.2.2:
|
||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1833,8 +1763,9 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
@@ -1973,9 +1904,9 @@ packages:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
@@ -2023,8 +1954,8 @@ packages:
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sass@1.94.2:
|
||||
resolution: {integrity: sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==}
|
||||
sass@1.63.6:
|
||||
resolution: {integrity: sha512-MJuxGMHzaOW7ipp+1KdELtqKbfAWbH7OLIdoSMnVe3EXPMTmxTmlaZDCTsgIpPCs3w99lLo9/zDKkOrJuT5byw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2641,67 +2572,6 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
dependencies:
|
||||
detect-libc: 1.0.3
|
||||
is-glob: 4.0.3
|
||||
micromatch: 4.0.8
|
||||
node-addon-api: 7.1.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher-android-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-x64': 2.5.1
|
||||
'@parcel/watcher-freebsd-x64': 2.5.1
|
||||
'@parcel/watcher-linux-arm-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm-musl': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-musl': 2.5.1
|
||||
'@parcel/watcher-linux-x64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-x64-musl': 2.5.1
|
||||
'@parcel/watcher-win32-arm64': 2.5.1
|
||||
'@parcel/watcher-win32-ia32': 2.5.1
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
optional: true
|
||||
|
||||
'@petamoriken/float16@3.9.3': {}
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
@@ -2946,6 +2816,11 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-query@5.3.2: {}
|
||||
@@ -3033,6 +2908,8 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bootstrap@5.3.8(@popperjs/core@2.11.8):
|
||||
dependencies:
|
||||
'@popperjs/core': 2.11.8
|
||||
@@ -3078,9 +2955,17 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chokidar@4.0.3:
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
anymatch: 3.1.3
|
||||
braces: 3.0.3
|
||||
glob-parent: 5.1.2
|
||||
is-binary-path: 2.1.0
|
||||
is-glob: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readdirp: 3.6.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
@@ -3148,9 +3033,6 @@ snapshots:
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
optional: true
|
||||
|
||||
detect-libc@2.1.2:
|
||||
optional: true
|
||||
|
||||
@@ -3590,6 +3472,9 @@ snapshots:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
function.prototype.name@1.1.8:
|
||||
@@ -3697,7 +3582,7 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immutable@5.1.4: {}
|
||||
immutable@4.3.7: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
@@ -3734,6 +3619,10 @@ snapshots:
|
||||
dependencies:
|
||||
has-bigints: 1.1.0
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-boolean-object@1.2.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -3940,7 +3829,7 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
next@15.5.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.94.2):
|
||||
next@15.5.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.63.6):
|
||||
dependencies:
|
||||
'@next/env': 15.5.7
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -3958,14 +3847,13 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 15.5.7
|
||||
'@next/swc-win32-arm64-msvc': 15.5.7
|
||||
'@next/swc-win32-x64-msvc': 15.5.7
|
||||
sass: 1.94.2
|
||||
sass: 1.63.6
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
@@ -4119,7 +4007,9 @@ snapshots:
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
@@ -4184,13 +4074,11 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sass@1.94.2:
|
||||
sass@1.63.6:
|
||||
dependencies:
|
||||
chokidar: 4.0.3
|
||||
immutable: 5.1.4
|
||||
chokidar: 3.6.0
|
||||
immutable: 4.3.7
|
||||
source-map-js: 1.2.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher': 2.5.1
|
||||
|
||||
scheduler@0.26.0: {}
|
||||
|
||||
|
||||
29
src/app/api/zxdb/entries/[id]/route.ts
Normal file
29
src/app/api/zxdb/entries/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getEntryById } from "@/server/repo/zxdb";
|
||||
|
||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const raw = await ctx.params;
|
||||
const parsed = paramsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return new Response(JSON.stringify({ error: parsed.error.flatten() }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const id = parsed.data.id;
|
||||
const detail = await getEntryById(id);
|
||||
if (!detail) {
|
||||
return new Response(JSON.stringify({ error: "Not found" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(detail), {
|
||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
30
src/app/api/zxdb/genres/[id]/route.ts
Normal file
30
src/app/api/zxdb/genres/[id]/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { entriesByGenre } from "@/server/repo/zxdb";
|
||||
|
||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const querySchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
||||
const p = paramsSchema.safeParse(ctx.params);
|
||||
if (!p.success) {
|
||||
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||
}
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = querySchema.safeParse({
|
||||
page: searchParams.get("page") ?? undefined,
|
||||
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||
});
|
||||
if (!q.success) {
|
||||
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
|
||||
}
|
||||
const page = q.data.page ?? 1;
|
||||
const pageSize = q.data.pageSize ?? 20;
|
||||
const data = await entriesByGenre(p.data.id, page, pageSize);
|
||||
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
10
src/app/api/zxdb/genres/route.ts
Normal file
10
src/app/api/zxdb/genres/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { listGenres } from "@/server/repo/zxdb";
|
||||
|
||||
export async function GET() {
|
||||
const data = await listGenres();
|
||||
return new Response(JSON.stringify({ items: data }), {
|
||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
50
src/app/api/zxdb/labels/[id]/route.ts
Normal file
50
src/app/api/zxdb/labels/[id]/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
|
||||
|
||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const querySchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
||||
const p = paramsSchema.safeParse(ctx.params);
|
||||
if (!p.success) {
|
||||
return new Response(JSON.stringify({ error: p.error.flatten() }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = querySchema.safeParse({
|
||||
page: searchParams.get("page") ?? undefined,
|
||||
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||
});
|
||||
if (!q.success) {
|
||||
return new Response(JSON.stringify({ error: q.error.flatten() }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const id = p.data.id;
|
||||
const label = await getLabelById(id);
|
||||
if (!label) {
|
||||
return new Response(JSON.stringify({ error: "Not found" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const page = q.data.page ?? 1;
|
||||
const pageSize = q.data.pageSize ?? 20;
|
||||
const [authored, published] = await Promise.all([
|
||||
getLabelAuthoredEntries(id, { page, pageSize }),
|
||||
getLabelPublishedEntries(id, { page, pageSize }),
|
||||
]);
|
||||
return new Response(
|
||||
JSON.stringify({ label, authored, published }),
|
||||
{ headers: { "content-type": "application/json", "cache-control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
30
src/app/api/zxdb/labels/search/route.ts
Normal file
30
src/app/api/zxdb/labels/search/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { searchLabels } from "@/server/repo/zxdb";
|
||||
|
||||
const querySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const parsed = querySchema.safeParse({
|
||||
q: searchParams.get("q") ?? undefined,
|
||||
page: searchParams.get("page") ?? undefined,
|
||||
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: parsed.error.flatten() }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
const data = await searchLabels(parsed.data);
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
30
src/app/api/zxdb/languages/[id]/route.ts
Normal file
30
src/app/api/zxdb/languages/[id]/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { entriesByLanguage } from "@/server/repo/zxdb";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().trim().length(2) });
|
||||
const querySchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
||||
const p = paramsSchema.safeParse(ctx.params);
|
||||
if (!p.success) {
|
||||
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||
}
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = querySchema.safeParse({
|
||||
page: searchParams.get("page") ?? undefined,
|
||||
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||
});
|
||||
if (!q.success) {
|
||||
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
|
||||
}
|
||||
const page = q.data.page ?? 1;
|
||||
const pageSize = q.data.pageSize ?? 20;
|
||||
const data = await entriesByLanguage(p.data.id, page, pageSize);
|
||||
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
10
src/app/api/zxdb/languages/route.ts
Normal file
10
src/app/api/zxdb/languages/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { listLanguages } from "@/server/repo/zxdb";
|
||||
|
||||
export async function GET() {
|
||||
const data = await listLanguages();
|
||||
return new Response(JSON.stringify({ items: data }), {
|
||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
30
src/app/api/zxdb/machinetypes/[id]/route.ts
Normal file
30
src/app/api/zxdb/machinetypes/[id]/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { entriesByMachinetype } from "@/server/repo/zxdb";
|
||||
|
||||
const paramsSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const querySchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
||||
const p = paramsSchema.safeParse(ctx.params);
|
||||
if (!p.success) {
|
||||
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||
}
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = querySchema.safeParse({
|
||||
page: searchParams.get("page") ?? undefined,
|
||||
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||
});
|
||||
if (!q.success) {
|
||||
return new Response(JSON.stringify({ error: q.error.flatten() }), { status: 400 });
|
||||
}
|
||||
const page = q.data.page ?? 1;
|
||||
const pageSize = q.data.pageSize ?? 20;
|
||||
const data = await entriesByMachinetype(p.data.id, page, pageSize);
|
||||
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
10
src/app/api/zxdb/machinetypes/route.ts
Normal file
10
src/app/api/zxdb/machinetypes/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { listMachinetypes } from "@/server/repo/zxdb";
|
||||
|
||||
export async function GET() {
|
||||
const data = await listMachinetypes();
|
||||
return new Response(JSON.stringify({ items: data }), {
|
||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -6,6 +6,14 @@ const querySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||
genreId: z.coerce.number().int().positive().optional(),
|
||||
languageId: z
|
||||
.string()
|
||||
.trim()
|
||||
.length(2, "languageId must be a 2-char code")
|
||||
.optional(),
|
||||
machinetypeId: z.coerce.number().int().positive().optional(),
|
||||
sort: z.enum(["title", "id_desc"]).optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -14,6 +22,10 @@ export async function GET(req: NextRequest) {
|
||||
q: searchParams.get("q") ?? undefined,
|
||||
page: searchParams.get("page") ?? undefined,
|
||||
pageSize: searchParams.get("pageSize") ?? undefined,
|
||||
genreId: searchParams.get("genreId") ?? undefined,
|
||||
languageId: searchParams.get("languageId") ?? undefined,
|
||||
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
||||
sort: searchParams.get("sort") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
|
||||
@@ -5,9 +5,10 @@ import RegisterDetail from '@/app/registers/RegisterDetail';
|
||||
import {Container, Row} from "react-bootstrap";
|
||||
import { getRegisters } from '@/services/register.service';
|
||||
|
||||
export default async function RegisterDetailPage({ params }: { params: { hex: string } }) {
|
||||
export default async function RegisterDetailPage({ params }: { params: Promise<{ hex: string }> }) {
|
||||
const registers = await getRegisters();
|
||||
const targetHex = decodeURIComponent((await params).hex).toLowerCase();
|
||||
const { hex } = await params;
|
||||
const targetHex = decodeURIComponent(hex).toLowerCase();
|
||||
|
||||
const register = registers.find(r => r.hex_address.toLowerCase() === targetHex);
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ export default function ZxdbExplorer() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
||||
const [genres, setGenres] = useState<{ id: number; name: string }[]>([]);
|
||||
const [languages, setLanguages] = useState<{ id: string; name: string }[]>([]);
|
||||
const [machines, setMachines] = useState<{ id: number; name: string }[]>([]);
|
||||
const [genreId, setGenreId] = useState<number | "">("");
|
||||
const [languageId, setLanguageId] = useState<string | "">("");
|
||||
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
|
||||
const [sort, setSort] = useState<"title" | "id_desc">("title");
|
||||
|
||||
const pageSize = 20;
|
||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||
@@ -33,6 +40,10 @@ export default function ZxdbExplorer() {
|
||||
if (query) params.set("q", query);
|
||||
params.set("page", String(p));
|
||||
params.set("pageSize", String(pageSize));
|
||||
if (genreId !== "") params.set("genreId", String(genreId));
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const json: Paged<Item> = await res.json();
|
||||
@@ -49,7 +60,24 @@ export default function ZxdbExplorer() {
|
||||
useEffect(() => {
|
||||
fetchData(q, page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
}, [page, genreId, languageId, machinetypeId, sort]);
|
||||
|
||||
// Load filter lists once
|
||||
useEffect(() => {
|
||||
async function loadLists() {
|
||||
try {
|
||||
const [g, l, m] = await Promise.all([
|
||||
fetch("/api/zxdb/genres", { cache: "no-store" }).then((r) => r.json()),
|
||||
fetch("/api/zxdb/languages", { cache: "no-store" }).then((r) => r.json()),
|
||||
fetch("/api/zxdb/machinetypes", { cache: "no-store" }).then((r) => r.json()),
|
||||
]);
|
||||
setGenres(g.items ?? []);
|
||||
setLanguages(l.items ?? []);
|
||||
setMachines(m.items ?? []);
|
||||
} catch {}
|
||||
}
|
||||
loadLists();
|
||||
}, []);
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -73,6 +101,36 @@ export default function ZxdbExplorer() {
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={genreId as any} onChange={(e) => setGenreId(e.target.value === "" ? "" : Number(e.target.value))}>
|
||||
<option value="">Genre</option>
|
||||
{genres.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={languageId as any} onChange={(e) => setLanguageId(e.target.value)}>
|
||||
<option value="">Language</option>
|
||||
{languages.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={machinetypeId as any} onChange={(e) => setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value))}>
|
||||
<option value="">Machine</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as any)}>
|
||||
<option value="title">Sort: Title</option>
|
||||
<option value="id_desc">Sort: Newest</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="col-auto text-secondary">Loading...</div>
|
||||
)}
|
||||
@@ -97,7 +155,9 @@ export default function ZxdbExplorer() {
|
||||
{data.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td>{it.id}</td>
|
||||
<td>{it.title}</td>
|
||||
<td>
|
||||
<a href={`/zxdb/entries/${it.id}`}>{it.title}</a>
|
||||
</td>
|
||||
<td>{it.machinetypeId ?? "-"}</td>
|
||||
<td>{it.languageId ?? "-"}</td>
|
||||
</tr>
|
||||
@@ -127,6 +187,14 @@ export default function ZxdbExplorer() {
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</a>
|
||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</a>
|
||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</a>
|
||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
109
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
109
src/app/zxdb/entries/[id]/EntryDetail.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||
type EntryDetail = {
|
||||
id: number;
|
||||
title: string;
|
||||
isXrated: number;
|
||||
machinetype: { id: number | null; name: string | null };
|
||||
language: { id: string | null; name: string | null };
|
||||
genre: { id: number | null; name: string | null };
|
||||
authors: Label[];
|
||||
publishers: Label[];
|
||||
};
|
||||
|
||||
export default function EntryDetailClient({ id }: { id: number }) {
|
||||
const [data, setData] = useState<EntryDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/zxdb/entries/${id}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const json: EntryDetail = await res.json();
|
||||
if (!aborted) setData(json);
|
||||
} catch (e: any) {
|
||||
if (!aborted) setError(e?.message ?? "Failed to load");
|
||||
} finally {
|
||||
if (!aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error) return <div className="alert alert-danger">{error}</div>;
|
||||
if (!data) return <div className="alert alert-warning">Not found</div>;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h1 className="mb-0">{data.title}</h1>
|
||||
{data.genre.name && (
|
||||
<a className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
|
||||
{data.genre.name}
|
||||
</a>
|
||||
)}
|
||||
{data.language.name && (
|
||||
<a className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
|
||||
{data.language.name}
|
||||
</a>
|
||||
)}
|
||||
{data.machinetype.name && (
|
||||
<a className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
|
||||
{data.machinetype.name}
|
||||
</a>
|
||||
)}
|
||||
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="row g-4">
|
||||
<div className="col-lg-6">
|
||||
<h5>Authors</h5>
|
||||
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||
{data.authors.length > 0 && (
|
||||
<ul className="list-unstyled mb-0">
|
||||
{data.authors.map((a) => (
|
||||
<li key={a.id}>
|
||||
<a href={`/zxdb/labels/${a.id}`}>{a.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<h5>Publishers</h5>
|
||||
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>}
|
||||
{data.publishers.length > 0 && (
|
||||
<ul className="list-unstyled mb-0">
|
||||
{data.publishers.map((p) => (
|
||||
<li key={p.id}>
|
||||
<a href={`/zxdb/labels/${p.id}`}>{p.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<a className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</a>
|
||||
<a className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/zxdb/entries/[id]/page.tsx
Normal file
10
src/app/zxdb/entries/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import EntryDetailClient from "./EntryDetail";
|
||||
|
||||
export const metadata = {
|
||||
title: "ZXDB Entry",
|
||||
};
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <EntryDetailClient id={Number(id)} />;
|
||||
}
|
||||
37
src/app/zxdb/genres/GenreList.tsx
Normal file
37
src/app/zxdb/genres/GenreList.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Genre = { id: number; name: string };
|
||||
|
||||
export default function GenreList() {
|
||||
const [items, setItems] = useState<Genre[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/zxdb/genres", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
setItems(json.items ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
return (
|
||||
<div>
|
||||
<h1>Genres</h1>
|
||||
<ul className="list-group">
|
||||
{items.map((g) => (
|
||||
<li key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href={`/zxdb/genres/${g.id}`}>{g.name}</a>
|
||||
<span className="badge text-bg-light">#{g.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/app/zxdb/genres/[id]/GenreDetail.tsx
Normal file
68
src/app/zxdb/genres/[id]/GenreDetail.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
|
||||
export default function GenreDetailClient({ id }: { id: number }) {
|
||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||
|
||||
async function load(p: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/zxdb/genres/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
|
||||
const json = (await res.json()) as Paged<Item>;
|
||||
setData(json);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Genre #{id}</h1>
|
||||
{loading && <div>Loading…</div>}
|
||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 80 }}>ID</th>
|
||||
<th>Title</th>
|
||||
<th style={{ width: 120 }}>Machine</th>
|
||||
<th style={{ width: 80 }}>Lang</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td>{it.id}</td>
|
||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
||||
<td>{it.machinetypeId ?? "-"}</td>
|
||||
<td>{it.languageId ?? "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
||||
<span>Page {data?.page ?? page} / {totalPages}</span>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/app/zxdb/genres/[id]/page.tsx
Normal file
8
src/app/zxdb/genres/[id]/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import GenreDetailClient from "./GenreDetail";
|
||||
|
||||
export const metadata = { title: "ZXDB Genre" };
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <GenreDetailClient id={Number(id)} />;
|
||||
}
|
||||
7
src/app/zxdb/genres/page.tsx
Normal file
7
src/app/zxdb/genres/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import GenreList from "./GenreList";
|
||||
|
||||
export const metadata = { title: "ZXDB Genres" };
|
||||
|
||||
export default function Page() {
|
||||
return <GenreList />;
|
||||
}
|
||||
84
src/app/zxdb/labels/LabelsSearch.tsx
Normal file
84
src/app/zxdb/labels/LabelsSearch.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
|
||||
export default function LabelsSearch() {
|
||||
const [q, setQ] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<Paged<Label> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const pageSize = 20;
|
||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set("q", q);
|
||||
params.set("page", String(page));
|
||||
params.set("pageSize", String(pageSize));
|
||||
const res = await fetch(`/api/zxdb/labels/search?${params.toString()}`, { cache: "no-store" });
|
||||
const json = (await res.json()) as Paged<Label>;
|
||||
setData(json);
|
||||
} catch (e) {
|
||||
setData({ items: [], page: 1, pageSize, total: 0 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Labels</h1>
|
||||
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary" disabled={loading}>Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-3">
|
||||
{loading && <div>Loading…</div>}
|
||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No labels found.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<ul className="list-group">
|
||||
{data.items.map((l) => (
|
||||
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href={`/zxdb/labels/${l.id}`}>{l.name}</a>
|
||||
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
Page {data?.page ?? page} / {totalPages}
|
||||
</span>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
93
src/app/zxdb/labels/[id]/LabelDetail.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
|
||||
type Payload = { label: Label; authored: Paged<Item>; published: Paged<Item> };
|
||||
|
||||
export default function LabelDetailClient({ id }: { id: number }) {
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [tab, setTab] = useState<"authored" | "published">("authored");
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const current = useMemo(() => (data ? (tab === "authored" ? data.authored : data.published) : null), [data, tab]);
|
||||
const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]);
|
||||
|
||||
async function load(p: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(p), pageSize: "20" });
|
||||
const res = await fetch(`/api/zxdb/labels/${id}?${params.toString()}`, { cache: "no-store" });
|
||||
const json = (await res.json()) as Payload;
|
||||
setData(json);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
if (!data) return <div>{loading ? "Loading…" : "Not found"}</div>;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<h1 className="mb-0">{data.label.name}</h1>
|
||||
<div>
|
||||
<span className="badge text-bg-light">{data.label.labeltypeId ?? "?"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="nav nav-tabs mt-3">
|
||||
<li className="nav-item">
|
||||
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className={`nav-link ${tab === "published" ? "active" : ""}`} onClick={() => setTab("published")}>Published</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-3">
|
||||
{loading && <div>Loading…</div>}
|
||||
{current && current.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
||||
{current && current.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 80 }}>ID</th>
|
||||
<th>Title</th>
|
||||
<th style={{ width: 120 }}>Machine</th>
|
||||
<th style={{ width: 80 }}>Lang</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{current.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td>{it.id}</td>
|
||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
||||
<td>{it.machinetypeId ?? "-"}</td>
|
||||
<td>{it.languageId ?? "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
||||
<span>Page {page} / {totalPages}</span>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || page >= totalPages}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/app/zxdb/labels/[id]/page.tsx
Normal file
8
src/app/zxdb/labels/[id]/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import LabelDetailClient from "./LabelDetail";
|
||||
|
||||
export const metadata = { title: "ZXDB Label" };
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <LabelDetailClient id={Number(id)} />;
|
||||
}
|
||||
7
src/app/zxdb/labels/page.tsx
Normal file
7
src/app/zxdb/labels/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import LabelsSearch from "./LabelsSearch";
|
||||
|
||||
export const metadata = { title: "ZXDB Labels" };
|
||||
|
||||
export default function Page() {
|
||||
return <LabelsSearch />;
|
||||
}
|
||||
37
src/app/zxdb/languages/LanguageList.tsx
Normal file
37
src/app/zxdb/languages/LanguageList.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Language = { id: string; name: string };
|
||||
|
||||
export default function LanguageList() {
|
||||
const [items, setItems] = useState<Language[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/zxdb/languages", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
setItems(json.items ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
return (
|
||||
<div>
|
||||
<h1>Languages</h1>
|
||||
<ul className="list-group">
|
||||
{items.map((l) => (
|
||||
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href={`/zxdb/languages/${l.id}`}>{l.name}</a>
|
||||
<span className="badge text-bg-light">{l.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/app/zxdb/languages/[id]/LanguageDetail.tsx
Normal file
68
src/app/zxdb/languages/[id]/LanguageDetail.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
|
||||
export default function LanguageDetailClient({ id }: { id: string }) {
|
||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||
|
||||
async function load(p: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/zxdb/languages/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
|
||||
const json = (await res.json()) as Paged<Item>;
|
||||
setData(json);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Language {id}</h1>
|
||||
{loading && <div>Loading…</div>}
|
||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 80 }}>ID</th>
|
||||
<th>Title</th>
|
||||
<th style={{ width: 120 }}>Machine</th>
|
||||
<th style={{ width: 80 }}>Lang</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td>{it.id}</td>
|
||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
||||
<td>{it.machinetypeId ?? "-"}</td>
|
||||
<td>{it.languageId ?? "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
||||
<span>Page {data?.page ?? page} / {totalPages}</span>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/app/zxdb/languages/[id]/page.tsx
Normal file
8
src/app/zxdb/languages/[id]/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import LanguageDetailClient from "./LanguageDetail";
|
||||
|
||||
export const metadata = { title: "ZXDB Language" };
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <LanguageDetailClient id={id} />;
|
||||
}
|
||||
7
src/app/zxdb/languages/page.tsx
Normal file
7
src/app/zxdb/languages/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import LanguageList from "./LanguageList";
|
||||
|
||||
export const metadata = { title: "ZXDB Languages" };
|
||||
|
||||
export default function Page() {
|
||||
return <LanguageList />;
|
||||
}
|
||||
37
src/app/zxdb/machinetypes/MachineTypeList.tsx
Normal file
37
src/app/zxdb/machinetypes/MachineTypeList.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type MT = { id: number; name: string };
|
||||
|
||||
export default function MachineTypeList() {
|
||||
const [items, setItems] = useState<MT[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/zxdb/machinetypes", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
setItems(json.items ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
return (
|
||||
<div>
|
||||
<h1>Machine Types</h1>
|
||||
<ul className="list-group">
|
||||
{items.map((m) => (
|
||||
<li key={m.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href={`/zxdb/machinetypes/${m.id}`}>{m.name}</a>
|
||||
<span className="badge text-bg-light">#{m.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
Normal file
68
src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
|
||||
export default function MachineTypeDetailClient({ id }: { id: number }) {
|
||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||
|
||||
async function load(p: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/zxdb/machinetypes/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
|
||||
const json = (await res.json()) as Paged<Item>;
|
||||
setData(json);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Machine Type #{id}</h1>
|
||||
{loading && <div>Loading…</div>}
|
||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 80 }}>ID</th>
|
||||
<th>Title</th>
|
||||
<th style={{ width: 120 }}>Machine</th>
|
||||
<th style={{ width: 80 }}>Lang</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td>{it.id}</td>
|
||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
||||
<td>{it.machinetypeId ?? "-"}</td>
|
||||
<td>{it.languageId ?? "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
||||
<span>Page {data?.page ?? page} / {totalPages}</span>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||
import { db } from "@/server/db";
|
||||
import { entries, searchByTitles } from "@/server/schema/zxdb";
|
||||
import {
|
||||
entries,
|
||||
searchByTitles,
|
||||
labels,
|
||||
authors,
|
||||
publishers,
|
||||
languages,
|
||||
machinetypes,
|
||||
genretypes,
|
||||
} from "@/server/schema/zxdb";
|
||||
|
||||
export interface SearchParams {
|
||||
q?: string;
|
||||
page?: number; // 1-based
|
||||
pageSize?: number; // default 20
|
||||
// Optional simple filters (ANDed together)
|
||||
genreId?: number;
|
||||
languageId?: string;
|
||||
machinetypeId?: number;
|
||||
// Sorting
|
||||
sort?: "title" | "id_desc";
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
@@ -28,12 +43,30 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
||||
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const sort = params.sort ?? (q ? "title" : "id_desc");
|
||||
|
||||
if (q.length === 0) {
|
||||
// Default listing: return first page by id desc (no guaranteed ordering field; using id)
|
||||
// Apply optional filters even without q
|
||||
const whereClauses = [
|
||||
params.genreId ? eq(entries.genretypeId, params.genreId as any) : undefined,
|
||||
params.languageId ? eq(entries.languageId, params.languageId as any) : undefined,
|
||||
params.machinetypeId ? eq(entries.machinetypeId, params.machinetypeId as any) : undefined,
|
||||
].filter(Boolean) as any[];
|
||||
|
||||
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
|
||||
|
||||
const [items, [{ total }]] = await Promise.all([
|
||||
db.select().from(entries).orderBy(desc(entries.id)).limit(pageSize).offset(offset),
|
||||
db.execute(sql`select count(*) as total from ${entries}`) as Promise<any>,
|
||||
db
|
||||
.select()
|
||||
.from(entries)
|
||||
.where(whereExpr as any)
|
||||
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
db.execute(
|
||||
sql`select count(*) as total from ${entries} ${whereExpr ? sql`where ${whereExpr}` : sql``}`
|
||||
) as Promise<any>,
|
||||
]);
|
||||
return {
|
||||
items: items as any,
|
||||
@@ -66,9 +99,243 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
||||
.innerJoin(entries, eq(entries.id, searchByTitles.entryId))
|
||||
.where(like(searchByTitles.entryTitle, pattern))
|
||||
.groupBy(entries.id)
|
||||
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, page, pageSize, total };
|
||||
}
|
||||
|
||||
export interface LabelSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
labeltypeId: string | null;
|
||||
}
|
||||
|
||||
export interface EntryDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
isXrated: number;
|
||||
machinetype: { id: number | null; name: string | null };
|
||||
language: { id: string | null; name: string | null };
|
||||
genre: { id: number | null; name: string | null };
|
||||
authors: LabelSummary[];
|
||||
publishers: LabelSummary[];
|
||||
}
|
||||
|
||||
export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
||||
// Basic entry with lookups
|
||||
const rows = await db
|
||||
.select({
|
||||
id: entries.id,
|
||||
title: entries.title,
|
||||
isXrated: entries.isXrated,
|
||||
machinetypeId: entries.machinetypeId,
|
||||
machinetypeName: machinetypes.name,
|
||||
languageId: entries.languageId,
|
||||
languageName: languages.name,
|
||||
genreId: entries.genretypeId,
|
||||
genreName: genretypes.name,
|
||||
})
|
||||
.from(entries)
|
||||
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any))
|
||||
.where(eq(entries.id, id));
|
||||
|
||||
const base = rows[0];
|
||||
if (!base) return null;
|
||||
|
||||
// Authors
|
||||
const authorRows = await db
|
||||
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||
.from(authors)
|
||||
.innerJoin(labels, eq(labels.id, authors.labelId))
|
||||
.where(eq(authors.entryId, id))
|
||||
.groupBy(labels.id);
|
||||
|
||||
// Publishers
|
||||
const publisherRows = await db
|
||||
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||
.from(publishers)
|
||||
.innerJoin(labels, eq(labels.id, publishers.labelId))
|
||||
.where(eq(publishers.entryId, id))
|
||||
.groupBy(labels.id);
|
||||
|
||||
return {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
isXrated: base.isXrated as any,
|
||||
machinetype: { id: (base.machinetypeId as any) ?? null, name: (base.machinetypeName as any) ?? null },
|
||||
language: { id: (base.languageId as any) ?? null, name: (base.languageName as any) ?? null },
|
||||
genre: { id: (base.genreId as any) ?? null, name: (base.genreName as any) ?? null },
|
||||
authors: authorRows as any,
|
||||
publishers: publisherRows as any,
|
||||
};
|
||||
}
|
||||
|
||||
// ----- Labels -----
|
||||
|
||||
export interface LabelDetail extends LabelSummary {}
|
||||
|
||||
export interface LabelSearchParams {
|
||||
q?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export async function searchLabels(params: LabelSearchParams): Promise<PagedResult<LabelSummary>> {
|
||||
const q = (params.q ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
if (!q) {
|
||||
const [items, [{ total }]] = await Promise.all([
|
||||
db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset),
|
||||
db.execute(sql`select count(*) as total from ${labels}`) as Promise<any>,
|
||||
]);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
// Using helper search_by_names for efficiency
|
||||
const pattern = `%${q}%`;
|
||||
const countRows = await db
|
||||
.select({ total: sql<number>`count(distinct ${sql.identifier("label_id")})` })
|
||||
.from(sql`search_by_names` as any)
|
||||
.where(like(sql.identifier("label_name") as any, pattern));
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
|
||||
const items = await db
|
||||
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||
.from(sql`search_by_names` as any)
|
||||
.innerJoin(labels, eq(labels.id as any, sql.identifier("label_id") as any))
|
||||
.where(like(sql.identifier("label_name") as any, pattern))
|
||||
.groupBy(labels.id)
|
||||
.orderBy(labels.name)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, page, pageSize, total };
|
||||
}
|
||||
|
||||
export async function getLabelById(id: number): Promise<LabelDetail | null> {
|
||||
const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1);
|
||||
return (rows[0] as any) ?? null;
|
||||
}
|
||||
|
||||
export interface LabelContribsParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
|
||||
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const countRows = await db
|
||||
.select({ total: sql<number>`count(distinct ${authors.entryId})` })
|
||||
.from(authors)
|
||||
.where(eq(authors.labelId, labelId));
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
|
||||
const items = await db
|
||||
.select({
|
||||
id: entries.id,
|
||||
title: entries.title,
|
||||
isXrated: entries.isXrated,
|
||||
machinetypeId: entries.machinetypeId,
|
||||
languageId: entries.languageId,
|
||||
})
|
||||
.from(authors)
|
||||
.innerJoin(entries, eq(entries.id, authors.entryId))
|
||||
.where(eq(authors.labelId, labelId))
|
||||
.groupBy(entries.id)
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, page, pageSize, total };
|
||||
}
|
||||
|
||||
export async function getLabelPublishedEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
|
||||
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const countRows = await db
|
||||
.select({ total: sql<number>`count(distinct ${publishers.entryId})` })
|
||||
.from(publishers)
|
||||
.where(eq(publishers.labelId, labelId));
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
|
||||
const items = await db
|
||||
.select({
|
||||
id: entries.id,
|
||||
title: entries.title,
|
||||
isXrated: entries.isXrated,
|
||||
machinetypeId: entries.machinetypeId,
|
||||
languageId: entries.languageId,
|
||||
})
|
||||
.from(publishers)
|
||||
.innerJoin(entries, eq(entries.id, publishers.entryId))
|
||||
.where(eq(publishers.labelId, labelId))
|
||||
.groupBy(entries.id)
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, page, pageSize, total };
|
||||
}
|
||||
|
||||
// ----- Lookups lists and category browsing -----
|
||||
|
||||
export async function listGenres() {
|
||||
return db.select().from(genretypes).orderBy(genretypes.name);
|
||||
}
|
||||
export async function listLanguages() {
|
||||
return db.select().from(languages).orderBy(languages.name);
|
||||
}
|
||||
export async function listMachinetypes() {
|
||||
return db.select().from(machinetypes).orderBy(machinetypes.name);
|
||||
}
|
||||
|
||||
export async function entriesByGenre(genreId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.genretypeId} = ${genreId}`);
|
||||
const items = await db
|
||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||
.from(entries)
|
||||
.where(eq(entries.genretypeId, genreId as any))
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.languageId} = ${langId}`);
|
||||
const items = await db
|
||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||
.from(entries)
|
||||
.where(eq(entries.languageId, langId as any))
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.machinetypeId} = ${mtId}`);
|
||||
const items = await db
|
||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||
.from(entries)
|
||||
.where(eq(entries.machinetypeId, mtId as any))
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const entries = mysqlTable("entries", {
|
||||
isXrated: tinyint("is_xrated").notNull(),
|
||||
machinetypeId: tinyint("machinetype_id"),
|
||||
languageId: char("language_id", { length: 2 }),
|
||||
genretypeId: tinyint("genretype_id"),
|
||||
});
|
||||
|
||||
// Helper table created by ZXDB_help_search.sql
|
||||
@@ -16,3 +17,59 @@ export const searchByTitles = mysqlTable("search_by_titles", {
|
||||
});
|
||||
|
||||
export type Entry = typeof entries.$inferSelect;
|
||||
|
||||
// ZXDB labels (people/companies/teams)
|
||||
export const labels = mysqlTable("labels", {
|
||||
id: int("id").notNull().primaryKey(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
labeltypeId: char("labeltype_id", { length: 1 }),
|
||||
});
|
||||
|
||||
// Helper table for names search
|
||||
export const searchByNames = mysqlTable("search_by_names", {
|
||||
labelName: varchar("label_name", { length: 100 }).notNull(),
|
||||
labelId: int("label_id").notNull(),
|
||||
});
|
||||
|
||||
// Helper: entries by authors
|
||||
export const searchByAuthors = mysqlTable("search_by_authors", {
|
||||
labelId: int("label_id").notNull(),
|
||||
entryId: int("entry_id").notNull(),
|
||||
});
|
||||
|
||||
// Helper: entries by publishers
|
||||
export const searchByPublishers = mysqlTable("search_by_publishers", {
|
||||
labelId: int("label_id").notNull(),
|
||||
entryId: int("entry_id").notNull(),
|
||||
});
|
||||
|
||||
// Relations tables
|
||||
export const authors = mysqlTable("authors", {
|
||||
entryId: int("entry_id").notNull(),
|
||||
labelId: int("label_id").notNull(),
|
||||
teamId: int("team_id"),
|
||||
});
|
||||
|
||||
export const publishers = mysqlTable("publishers", {
|
||||
entryId: int("entry_id").notNull(),
|
||||
labelId: int("label_id").notNull(),
|
||||
});
|
||||
|
||||
// Lookups
|
||||
export const languages = mysqlTable("languages", {
|
||||
id: char("id", { length: 2 }).notNull().primaryKey(),
|
||||
// Column name in DB is `text`; map to `name` property for app ergonomics
|
||||
name: varchar("text", { length: 100 }).notNull(),
|
||||
});
|
||||
|
||||
export const machinetypes = mysqlTable("machinetypes", {
|
||||
id: tinyint("id").notNull().primaryKey(),
|
||||
// Column name in DB is `text`
|
||||
name: varchar("text", { length: 50 }).notNull(),
|
||||
});
|
||||
|
||||
export const genretypes = mysqlTable("genretypes", {
|
||||
id: tinyint("id").notNull().primaryKey(),
|
||||
// Column name in DB is `text`
|
||||
name: varchar("text", { length: 50 }).notNull(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user