diff --git a/AGENTS.md b/AGENTS.md index 3d35f2b..cca2128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/COMMIT_EDITMSG b/COMMIT_EDITMSG new file mode 100644 index 0000000..e9ff47f --- /dev/null +++ b/COMMIT_EDITMSG @@ -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 diff --git a/package.json b/package.json index 816612b..8c2a99d 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 562002e..a56d02a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/api/zxdb/entries/[id]/route.ts b/src/app/api/zxdb/entries/[id]/route.ts new file mode 100644 index 0000000..dca9e02 --- /dev/null +++ b/src/app/api/zxdb/entries/[id]/route.ts @@ -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"; diff --git a/src/app/api/zxdb/genres/[id]/route.ts b/src/app/api/zxdb/genres/[id]/route.ts new file mode 100644 index 0000000..6883bad --- /dev/null +++ b/src/app/api/zxdb/genres/[id]/route.ts @@ -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"; diff --git a/src/app/api/zxdb/genres/route.ts b/src/app/api/zxdb/genres/route.ts new file mode 100644 index 0000000..dac4631 --- /dev/null +++ b/src/app/api/zxdb/genres/route.ts @@ -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"; diff --git a/src/app/api/zxdb/labels/[id]/route.ts b/src/app/api/zxdb/labels/[id]/route.ts new file mode 100644 index 0000000..77c5dec --- /dev/null +++ b/src/app/api/zxdb/labels/[id]/route.ts @@ -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"; diff --git a/src/app/api/zxdb/labels/search/route.ts b/src/app/api/zxdb/labels/search/route.ts new file mode 100644 index 0000000..a5617c1 --- /dev/null +++ b/src/app/api/zxdb/labels/search/route.ts @@ -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"; diff --git a/src/app/api/zxdb/languages/[id]/route.ts b/src/app/api/zxdb/languages/[id]/route.ts new file mode 100644 index 0000000..8fb9c74 --- /dev/null +++ b/src/app/api/zxdb/languages/[id]/route.ts @@ -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"; diff --git a/src/app/api/zxdb/languages/route.ts b/src/app/api/zxdb/languages/route.ts new file mode 100644 index 0000000..0fce378 --- /dev/null +++ b/src/app/api/zxdb/languages/route.ts @@ -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"; diff --git a/src/app/api/zxdb/machinetypes/[id]/route.ts b/src/app/api/zxdb/machinetypes/[id]/route.ts new file mode 100644 index 0000000..940ad2c --- /dev/null +++ b/src/app/api/zxdb/machinetypes/[id]/route.ts @@ -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"; diff --git a/src/app/api/zxdb/machinetypes/route.ts b/src/app/api/zxdb/machinetypes/route.ts new file mode 100644 index 0000000..a559891 --- /dev/null +++ b/src/app/api/zxdb/machinetypes/route.ts @@ -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"; diff --git a/src/app/api/zxdb/search/route.ts b/src/app/api/zxdb/search/route.ts index d8fbec6..d9e0f16 100644 --- a/src/app/api/zxdb/search/route.ts +++ b/src/app/api/zxdb/search/route.ts @@ -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( diff --git a/src/app/registers/[hex]/page.tsx b/src/app/registers/[hex]/page.tsx index 7e2c4c8..14d02a5 100644 --- a/src/app/registers/[hex]/page.tsx +++ b/src/app/registers/[hex]/page.tsx @@ -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); diff --git a/src/app/zxdb/ZxdbExplorer.tsx b/src/app/zxdb/ZxdbExplorer.tsx index ad974c6..49ac9f3 100644 --- a/src/app/zxdb/ZxdbExplorer.tsx +++ b/src/app/zxdb/ZxdbExplorer.tsx @@ -22,6 +22,13 @@ export default function ZxdbExplorer() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [data, setData] = useState | 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(""); + const [languageId, setLanguageId] = useState(""); + const [machinetypeId, setMachinetypeId] = useState(""); + 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 = 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() {
+
+ +
+
+ +
+
+ +
+
+ +
{loading && (
Loading...
)} @@ -97,7 +155,9 @@ export default function ZxdbExplorer() { {data.items.map((it) => ( {it.id} - {it.title} + + {it.title} + {it.machinetypeId ?? "-"} {it.languageId ?? "-"} @@ -127,6 +187,14 @@ export default function ZxdbExplorer() { Next + +
+ ); } diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx new file mode 100644 index 0000000..62252f9 --- /dev/null +++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Loading…
; + if (error) return
{error}
; + if (!data) return
Not found
; + + return ( +
+
+

{data.title}

+ {data.genre.name && ( + + {data.genre.name} + + )} + {data.language.name && ( + + {data.language.name} + + )} + {data.machinetype.name && ( + + {data.machinetype.name} + + )} + {data.isXrated ? 18+ : null} +
+ +
+ +
+
+
Authors
+ {data.authors.length === 0 &&
Unknown
} + {data.authors.length > 0 && ( +
    + {data.authors.map((a) => ( +
  • + {a.name} +
  • + ))} +
+ )} +
+
+
Publishers
+ {data.publishers.length === 0 &&
Unknown
} + {data.publishers.length > 0 && ( +
    + {data.publishers.map((p) => ( +
  • + {p.name} +
  • + ))} +
+ )} +
+
+ +
+ + +
+ ); +} diff --git a/src/app/zxdb/entries/[id]/page.tsx b/src/app/zxdb/entries/[id]/page.tsx new file mode 100644 index 0000000..9eadf23 --- /dev/null +++ b/src/app/zxdb/entries/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/zxdb/genres/GenreList.tsx b/src/app/zxdb/genres/GenreList.tsx new file mode 100644 index 0000000..4e1c706 --- /dev/null +++ b/src/app/zxdb/genres/GenreList.tsx @@ -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([]); + 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
Loading…
; + return ( +
+

Genres

+
    + {items.map((g) => ( +
  • + {g.name} + #{g.id} +
  • + ))} +
+
+ ); +} diff --git a/src/app/zxdb/genres/[id]/GenreDetail.tsx b/src/app/zxdb/genres/[id]/GenreDetail.tsx new file mode 100644 index 0000000..8059480 --- /dev/null +++ b/src/app/zxdb/genres/[id]/GenreDetail.tsx @@ -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 = { items: T[]; page: number; pageSize: number; total: number }; + +export default function GenreDetailClient({ id }: { id: number }) { + const [data, setData] = useState | 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; + setData(json); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(page); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page]); + + return ( +
+

Genre #{id}

+ {loading &&
Loading…
} + {data && data.items.length === 0 && !loading &&
No entries.
} + {data && data.items.length > 0 && ( +
+ + + + + + + + + + + {data.items.map((it) => ( + + + + + + + ))} + +
IDTitleMachineLang
{it.id}{it.title}{it.machinetypeId ?? "-"}{it.languageId ?? "-"}
+
+ )} + +
+ + Page {data?.page ?? page} / {totalPages} + +
+
+ ); +} diff --git a/src/app/zxdb/genres/[id]/page.tsx b/src/app/zxdb/genres/[id]/page.tsx new file mode 100644 index 0000000..4bbb929 --- /dev/null +++ b/src/app/zxdb/genres/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/zxdb/genres/page.tsx b/src/app/zxdb/genres/page.tsx new file mode 100644 index 0000000..b5d1468 --- /dev/null +++ b/src/app/zxdb/genres/page.tsx @@ -0,0 +1,7 @@ +import GenreList from "./GenreList"; + +export const metadata = { title: "ZXDB Genres" }; + +export default function Page() { + return ; +} diff --git a/src/app/zxdb/labels/LabelsSearch.tsx b/src/app/zxdb/labels/LabelsSearch.tsx new file mode 100644 index 0000000..06cc402 --- /dev/null +++ b/src/app/zxdb/labels/LabelsSearch.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +type Label = { id: number; name: string; labeltypeId: string | null }; +type Paged = { 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 | 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