Compare commits
2 Commits
main
...
ce6803b0e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce6803b0e0 | ||
|
|
23989beec4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,5 +9,4 @@ medialore.db-shm
|
|||||||
medialore.db-wal
|
medialore.db-wal
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.session_secret
|
.session_secret
|
||||||
.vscode/
|
.vscode/
|
||||||
*.traineddata
|
|
||||||
@@ -45,11 +45,6 @@ COPY --from=builder /app/.next/static ./.next/static
|
|||||||
COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||||
COPY --from=deps /app/node_modules/sharp ./node_modules/sharp
|
COPY --from=deps /app/node_modules/sharp ./node_modules/sharp
|
||||||
COPY --from=deps /app/node_modules/@img ./node_modules/@img
|
COPY --from=deps /app/node_modules/@img ./node_modules/@img
|
||||||
# tesseract.js loads its worker via worker_threads using a runtime-constructed path,
|
|
||||||
# so the standalone file tracer never discovers src/worker-script/node/. Copy the
|
|
||||||
# full package so that path resolves correctly at runtime.
|
|
||||||
COPY --from=deps /app/node_modules/tesseract.js ./node_modules/tesseract.js
|
|
||||||
COPY --from=deps /app/node_modules/tesseract.js-core ./node_modules/tesseract.js-core
|
|
||||||
|
|
||||||
# Create thumbnail cache directory (mounted as a volume in production)
|
# Create thumbnail cache directory (mounted as a volume in production)
|
||||||
RUN mkdir -p /app/.thumbnails
|
RUN mkdir -p /app/.thumbnails
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
serverExternalPackages: ['better-sqlite3', 'sharp', 'tesseract.js'],
|
serverExternalPackages: ['better-sqlite3', 'sharp'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
251
package-lock.json
generated
251
package-lock.json
generated
@@ -9,9 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
|
||||||
"@types/adm-zip": "^0.5.8",
|
|
||||||
"adm-zip": "^0.5.17",
|
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
@@ -20,8 +17,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5"
|
||||||
"tesseract.js": "^7.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
@@ -1147,9 +1143,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz",
|
||||||
"integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==",
|
"integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -1163,9 +1159,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz",
|
||||||
"integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==",
|
"integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1179,9 +1175,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz",
|
||||||
"integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==",
|
"integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1195,9 +1191,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz",
|
||||||
"integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==",
|
"integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1214,9 +1210,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz",
|
||||||
"integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==",
|
"integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1233,9 +1229,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz",
|
||||||
"integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==",
|
"integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1252,9 +1248,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz",
|
||||||
"integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==",
|
"integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1271,9 +1267,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz",
|
||||||
"integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==",
|
"integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1287,9 +1283,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz",
|
||||||
"integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==",
|
"integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1659,33 +1655,6 @@
|
|||||||
"tailwindcss": "4.2.2"
|
"tailwindcss": "4.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
|
||||||
"version": "3.13.24",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
|
|
||||||
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/virtual-core": "3.14.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/virtual-core": {
|
|
||||||
"version": "3.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
|
|
||||||
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1697,15 +1666,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/adm-zip": {
|
|
||||||
"version": "0.5.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
|
|
||||||
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/archiver": {
|
"node_modules/@types/archiver": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
||||||
@@ -1751,6 +1711,7 @@
|
|||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@@ -2403,15 +2364,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/adm-zip": {
|
|
||||||
"version": "0.5.17",
|
|
||||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
|
||||||
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
@@ -2998,16 +2950,10 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bmp-js": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4857,12 +4803,6 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb-keyval": {
|
|
||||||
"version": "6.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
|
||||||
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -5348,12 +5288,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-url": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -6104,12 +6038,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.15",
|
"version": "15.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
|
||||||
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
|
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.15",
|
"@next/env": "15.5.14",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -6122,14 +6056,14 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.5.15",
|
"@next/swc-darwin-arm64": "15.5.14",
|
||||||
"@next/swc-darwin-x64": "15.5.15",
|
"@next/swc-darwin-x64": "15.5.14",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.15",
|
"@next/swc-linux-arm64-gnu": "15.5.14",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.15",
|
"@next/swc-linux-arm64-musl": "15.5.14",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.15",
|
"@next/swc-linux-x64-gnu": "15.5.14",
|
||||||
"@next/swc-linux-x64-musl": "15.5.15",
|
"@next/swc-linux-x64-musl": "15.5.14",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.15",
|
"@next/swc-win32-arm64-msvc": "15.5.14",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.15",
|
"@next/swc-win32-x64-msvc": "15.5.14",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -6233,26 +6167,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "4.x || >=6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"encoding": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"encoding": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
@@ -6401,15 +6315,6 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/opencollective-postinstall": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"opencollective-postinstall": "index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -6842,12 +6747,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
|
||||||
"version": "0.13.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
@@ -7686,30 +7585,6 @@
|
|||||||
"streamx": "^2.12.5"
|
"streamx": "^2.12.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tesseract.js": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"bmp-js": "^0.1.0",
|
|
||||||
"idb-keyval": "^6.2.0",
|
|
||||||
"is-url": "^1.2.4",
|
|
||||||
"node-fetch": "^2.6.9",
|
|
||||||
"opencollective-postinstall": "^2.0.3",
|
|
||||||
"regenerator-runtime": "^0.13.3",
|
|
||||||
"tesseract.js-core": "^7.0.0",
|
|
||||||
"wasm-feature-detect": "^1.8.0",
|
|
||||||
"zlibjs": "^0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tesseract.js-core": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/text-decoder": {
|
"node_modules/text-decoder": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||||
@@ -7780,12 +7655,6 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -8001,6 +7870,7 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
@@ -8085,28 +7955,6 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/wasm-feature-detect": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -8389,15 +8237,6 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zlibjs": {
|
|
||||||
"version": "0.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
|
||||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
|
||||||
"@types/adm-zip": "^0.5.8",
|
|
||||||
"adm-zip": "^0.5.17",
|
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
@@ -23,8 +20,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5"
|
||||||
"tesseract.js": "^7.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ export async function PUT(
|
|||||||
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
|
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
|
||||||
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
|
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
|
||||||
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
|
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
|
||||||
maxTokensTag: typeof body.maxTokensTag === 'number' ? body.maxTokensTag : (body.maxTokensTag === null ? null : undefined),
|
|
||||||
maxTokensDescribe: typeof body.maxTokensDescribe === 'number' ? body.maxTokensDescribe : (body.maxTokensDescribe === null ? null : undefined),
|
|
||||||
maxTokensExtract: typeof body.maxTokensExtract === 'number' ? body.maxTokensExtract : (body.maxTokensExtract === null ? null : undefined),
|
|
||||||
maxTokensTranslate: typeof body.maxTokensTranslate === 'number' ? body.maxTokensTranslate : (body.maxTokensTranslate === null ? null : undefined),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json(getLibraryAiOverrides(id))
|
return NextResponse.json(getLibraryAiOverrides(id))
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { requireAuth } from '@/lib/auth'
|
|
||||||
import { getAiConfig } from '@/lib/app-settings'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const auth = await requireAuth(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const { ocrMode, ocrLanguages } = getAiConfig()
|
|
||||||
return NextResponse.json({ ocrMode, ocrLanguages })
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries, type OcrMode } from '@/lib/app-settings'
|
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries } from '@/lib/app-settings'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const auth = await requireAdmin(request)
|
const auth = await requireAdmin(request)
|
||||||
@@ -30,13 +30,6 @@ export async function PUT(request: NextRequest) {
|
|||||||
promptExtract?: string
|
promptExtract?: string
|
||||||
promptTranslate?: string
|
promptTranslate?: string
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
maxTokensTag?: number
|
|
||||||
maxTokensDescribe?: number
|
|
||||||
maxTokensExtract?: number
|
|
||||||
maxTokensTranslate?: number
|
|
||||||
ocrMode?: string
|
|
||||||
ocrLanguages?: string
|
|
||||||
ocrConfidenceThreshold?: number
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -49,8 +42,6 @@ export async function PUT(request: NextRequest) {
|
|||||||
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
||||||
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
|
||||||
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (typeof endpoint !== 'string') {
|
if (typeof endpoint !== 'string') {
|
||||||
@@ -75,13 +66,6 @@ export async function PUT(request: NextRequest) {
|
|||||||
typeof promptTagger === 'string' ? promptTagger : undefined,
|
typeof promptTagger === 'string' ? promptTagger : undefined,
|
||||||
typeof promptExtract === 'string' ? promptExtract : undefined,
|
typeof promptExtract === 'string' ? promptExtract : undefined,
|
||||||
typeof promptTranslate === 'string' ? promptTranslate : undefined,
|
typeof promptTranslate === 'string' ? promptTranslate : undefined,
|
||||||
typeof maxTokensTag === 'number' ? maxTokensTag : undefined,
|
|
||||||
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
|
|
||||||
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
|
|
||||||
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
|
|
||||||
(ocrMode === 'hybrid' || ocrMode === 'tesseract' || ocrMode === 'llm') ? (ocrMode as OcrMode) : undefined,
|
|
||||||
typeof ocrLanguages === 'string' ? ocrLanguages : undefined,
|
|
||||||
typeof ocrConfidenceThreshold === 'number' ? ocrConfidenceThreshold : undefined,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
|
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
||||||
|
|||||||
@@ -1,33 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string }
|
let body: { itemKey?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemKey, ocrLanguages, ocrMode } = body
|
const { itemKey } = body
|
||||||
if (!itemKey || typeof itemKey !== 'string') {
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const payload: Record<string, string> = {}
|
const jobId = enqueueJob(itemKey, 'extract', libraryId)
|
||||||
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
|
|
||||||
if (ocrMode) payload.ocrMode = ocrMode
|
|
||||||
const jobId = enqueueJob(
|
|
||||||
itemKey,
|
|
||||||
'extract',
|
|
||||||
libraryId,
|
|
||||||
undefined,
|
|
||||||
Object.keys(payload).length ? payload : undefined,
|
|
||||||
)
|
|
||||||
return NextResponse.json({ jobId }, { status: 202 })
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
|
import { getAiFields, updateExtractedText } from '@/lib/ai-tagger'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -19,37 +19,25 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
|
let body: { itemKey?: string; extractedText?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemKey, extractedText, aiDescription } = body
|
const { itemKey, extractedText } = body
|
||||||
if (!itemKey || typeof itemKey !== 'string') {
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
if (extractedText === undefined && aiDescription === undefined) {
|
if (typeof extractedText !== 'string') {
|
||||||
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
|
return NextResponse.json({ error: 'extractedText is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
if (extractedText !== undefined) {
|
updateExtractedText(itemKey, extractedText)
|
||||||
if (typeof extractedText !== 'string') {
|
|
||||||
return NextResponse.json({ error: 'extractedText must be a string' }, { status: 400 })
|
|
||||||
}
|
|
||||||
updateExtractedText(itemKey, extractedText)
|
|
||||||
}
|
|
||||||
if (aiDescription !== undefined) {
|
|
||||||
if (typeof aiDescription !== 'string') {
|
|
||||||
return NextResponse.json({ error: 'aiDescription must be a string' }, { status: 400 })
|
|
||||||
}
|
|
||||||
updateAiDescription(itemKey, aiDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { requireLibraryWriteAccess } from '@/lib/auth'
|
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
|
||||||
import { getDb } from '@/lib/db'
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
let body: { libraryId?: string; path?: string }
|
|
||||||
try {
|
|
||||||
body = await request.json()
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { libraryId, path: dirPath } = body
|
|
||||||
if (!libraryId || typeof libraryId !== 'string') {
|
|
||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const prefix = dirPath
|
|
||||||
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
|
|
||||||
: `${libraryId}:mixed_file:`
|
|
||||||
|
|
||||||
// Only enqueue translate jobs for items that already have extracted text
|
|
||||||
const items = db
|
|
||||||
.prepare(
|
|
||||||
'SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = ? AND extracted_text IS NOT NULL'
|
|
||||||
)
|
|
||||||
.all(`${prefix}%`, 'mixed_file') as { item_key: string }[]
|
|
||||||
|
|
||||||
const jobIds = items.map(({ item_key }) => enqueueJob(item_key, 'translate', libraryId))
|
|
||||||
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
|
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
|
||||||
@@ -30,72 +29,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const root = resolveLibraryRoot(library)
|
const root = resolveLibraryRoot(library)
|
||||||
const recursive = request.nextUrl.searchParams.get('recursive') === 'true'
|
const recursive = request.nextUrl.searchParams.get('recursive') === 'true'
|
||||||
const listing = recursive
|
const listing = recursive
|
||||||
? await scanDirectoryRecursive(root, libraryId, subpath)
|
? scanDirectoryRecursive(root, libraryId, subpath)
|
||||||
: scanDirectory(root, libraryId, subpath)
|
: scanDirectory(root, libraryId, subpath)
|
||||||
|
|
||||||
// Annotate entries with metadata used by search/filtering in mixed view.
|
|
||||||
const db = getDb()
|
|
||||||
const metadataRows = db
|
|
||||||
.prepare(`
|
|
||||||
SELECT item_key, user_rating, ai_description, extracted_text, extracted_text_translated
|
|
||||||
FROM media_items
|
|
||||||
WHERE library_id = ?
|
|
||||||
AND (
|
|
||||||
user_rating IS NOT NULL
|
|
||||||
OR ai_description IS NOT NULL
|
|
||||||
OR extracted_text IS NOT NULL
|
|
||||||
OR extracted_text_translated IS NOT NULL
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.all(libraryId) as {
|
|
||||||
item_key: string
|
|
||||||
user_rating: number | null
|
|
||||||
ai_description: string | null
|
|
||||||
extracted_text: string | null
|
|
||||||
extracted_text_translated: string | null
|
|
||||||
}[]
|
|
||||||
|
|
||||||
const metadataByItemKey = new Map(metadataRows.map((r) => [r.item_key, r]))
|
|
||||||
const withText = new Set(
|
|
||||||
metadataRows
|
|
||||||
.filter((r) => r.extracted_text !== null)
|
|
||||||
.map((r) => r.item_key)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build a set of all ancestor directory relative paths that contain at least one item with text
|
|
||||||
// e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1"
|
|
||||||
const dirsWithText = new Set<string>()
|
|
||||||
const keyPrefix = `${libraryId}:mixed_file:`
|
|
||||||
for (const key of withText) {
|
|
||||||
const decoded = decodeURIComponent(key.slice(keyPrefix.length))
|
|
||||||
const parts = decoded.split('/')
|
|
||||||
for (let i = 1; i < parts.length; i++) {
|
|
||||||
dirsWithText.add(parts.slice(0, i).join('/'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listing.entries = listing.entries.map((e) => {
|
|
||||||
if (e.type === 'file') {
|
|
||||||
// Recursive listing already uses full path from library root in e.name.
|
|
||||||
const relPath = recursive ? e.name : (subpath ? path.join(subpath, e.name) : e.name)
|
|
||||||
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
|
||||||
const metadata = metadataByItemKey.get(itemKey)
|
|
||||||
return {
|
|
||||||
...e,
|
|
||||||
...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}),
|
|
||||||
userRating: metadata?.user_rating ?? null,
|
|
||||||
aiDescription: metadata?.ai_description ?? null,
|
|
||||||
extractedText: metadata?.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: metadata?.extracted_text_translated ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.type === 'directory') {
|
|
||||||
const dirRel = subpath ? `${subpath}/${e.name}` : e.name
|
|
||||||
if (dirsWithText.has(dirRel)) return { ...e, hasExtractedText: true }
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(listing)
|
return NextResponse.json(listing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
|
||||||
import { getComicPageBuffer } from '@/lib/comics'
|
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
|
||||||
import { getDb } from '@/lib/db'
|
|
||||||
|
|
||||||
const EXT_TO_MIME: Record<string, string> = {
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.webp': 'image/webp',
|
|
||||||
'.gif': 'image/gif',
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = request.nextUrl
|
|
||||||
const libraryId = searchParams.get('libraryId')
|
|
||||||
const issueKey = searchParams.get('issueKey')
|
|
||||||
const pageIndexStr = searchParams.get('pageIndex')
|
|
||||||
|
|
||||||
if (!libraryId || !issueKey || pageIndexStr === null) {
|
|
||||||
return NextResponse.json({ error: 'Missing libraryId, issueKey, or pageIndex' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageIndex = parseInt(pageIndexStr, 10)
|
|
||||||
if (isNaN(pageIndex) || pageIndex < 0) {
|
|
||||||
return NextResponse.json({ error: 'Invalid pageIndex' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
|
||||||
if (!library) {
|
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
|
|
||||||
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
|
|
||||||
|
|
||||||
if (!row?.file_path) {
|
|
||||||
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = resolveLibraryRoot(library)
|
|
||||||
|
|
||||||
let absPath: string
|
|
||||||
try {
|
|
||||||
absPath = resolveAndJail(root, row.file_path)
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = getComicPageBuffer(absPath, pageIndex)
|
|
||||||
if (!result) {
|
|
||||||
return NextResponse.json({ error: 'Page not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = EXT_TO_MIME[result.ext] ?? 'image/jpeg'
|
|
||||||
|
|
||||||
return new NextResponse(result.buffer as unknown as BodyInit, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': mimeType,
|
|
||||||
'Content-Length': String(result.buffer.length),
|
|
||||||
'Cache-Control': 'public, max-age=86400',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
|
||||||
import { comicsFromDb, comicIssuesFromDb } from '@/lib/comics'
|
|
||||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
|
||||||
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
|
||||||
import { getDb } from '@/lib/db'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = request.nextUrl
|
|
||||||
const libraryId = searchParams.get('libraryId')
|
|
||||||
const seriesId = searchParams.get('seriesId')
|
|
||||||
|
|
||||||
if (!libraryId) {
|
|
||||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
|
||||||
if (!library) {
|
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (library.type !== 'comics') {
|
|
||||||
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seriesId) {
|
|
||||||
return NextResponse.json(comicIssuesFromDb(libraryId, seriesId))
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)
|
|
||||||
const pageSize = Math.min(500, Math.max(1, parseInt(searchParams.get('pageSize') ?? '200', 10) || 200))
|
|
||||||
const search = (searchParams.get('search') ?? '').trim() || undefined
|
|
||||||
|
|
||||||
return NextResponse.json(comicsFromDb(libraryId, { page, pageSize, search }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const { searchParams } = request.nextUrl
|
|
||||||
const libraryId = searchParams.get('libraryId')
|
|
||||||
const issueKey = searchParams.get('issueKey')
|
|
||||||
const seriesId = searchParams.get('seriesId')
|
|
||||||
|
|
||||||
if (!libraryId) {
|
|
||||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
|
||||||
if (!library) {
|
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (library.type !== 'comics') {
|
|
||||||
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = resolveLibraryRoot(library)
|
|
||||||
|
|
||||||
if (issueKey) {
|
|
||||||
const db = getDb()
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
|
|
||||||
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
|
|
||||||
|
|
||||||
if (!row?.file_path) {
|
|
||||||
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
let issuePath: string
|
|
||||||
try {
|
|
||||||
issuePath = resolveAndJail(root, row.file_path)
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid issue path' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(issuePath)
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Failed to delete issue file' }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAllAssignmentsForItem(issueKey)
|
|
||||||
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(issueKey)
|
|
||||||
|
|
||||||
return new NextResponse(null, { status: 204 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seriesId) {
|
|
||||||
const dirName = decodeURIComponent(seriesId)
|
|
||||||
|
|
||||||
let seriesDir: string
|
|
||||||
try {
|
|
||||||
seriesDir = resolveAndJail(root, dirName)
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid series path' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.rmSync(seriesDir, { recursive: true, force: true })
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAllAssignmentsForItem(`${libraryId}:comic_series:${seriesId}`)
|
|
||||||
const db = getDb()
|
|
||||||
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(`${libraryId}:comic_series:${seriesId}`)
|
|
||||||
|
|
||||||
return new NextResponse(null, { status: 204 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Missing issueKey or seriesId' }, { status: 400 })
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getLibrary } from '@/lib/libraries'
|
|
||||||
import { getComicsSeriesIssueMeta } from '@/lib/tags'
|
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = request.nextUrl
|
|
||||||
const libraryId = searchParams.get('libraryId')
|
|
||||||
|
|
||||||
if (!libraryId) {
|
|
||||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
|
||||||
if (!library) {
|
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (library.type !== 'comics') {
|
|
||||||
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(getComicsSeriesIssueMeta(libraryId))
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
'.bmp': 'image/bmp',
|
'.bmp': 'image/bmp',
|
||||||
'.tiff': 'image/tiff',
|
'.tiff': 'image/tiff',
|
||||||
'.tif': 'image/tiff',
|
'.tif': 'image/tiff',
|
||||||
'.cbz': 'application/zip',
|
|
||||||
'.zip': 'application/zip',
|
'.zip': 'application/zip',
|
||||||
'.dmg': 'application/x-apple-diskimage',
|
'.dmg': 'application/x-apple-diskimage',
|
||||||
'.gz': 'application/gzip',
|
'.gz': 'application/gzip',
|
||||||
@@ -44,7 +43,6 @@ function getMimeType(filePath: string): string {
|
|||||||
function isDownloadAttachment(filePath: string): boolean {
|
function isDownloadAttachment(filePath: string): boolean {
|
||||||
const lower = filePath.toLowerCase()
|
const lower = filePath.toLowerCase()
|
||||||
return (
|
return (
|
||||||
lower.endsWith('.cbz') ||
|
|
||||||
lower.endsWith('.zip') ||
|
lower.endsWith('.zip') ||
|
||||||
lower.endsWith('.tar.gz') ||
|
lower.endsWith('.tar.gz') ||
|
||||||
lower.endsWith('.tar.bz2') ||
|
lower.endsWith('.tar.bz2') ||
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getImportedTagsForLibrary } from '@/lib/comic-metadata'
|
|
||||||
import { requireAdmin } from '@/lib/auth'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const libraryId = request.nextUrl.searchParams.get('libraryId')
|
|
||||||
if (!libraryId) {
|
|
||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = getImportedTagsForLibrary(libraryId)
|
|
||||||
return NextResponse.json(tags)
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { requireAdmin } from '@/lib/auth'
|
|
||||||
import { getLibrary } from '@/lib/libraries'
|
|
||||||
import { getDb } from '@/lib/db'
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const { id: libraryId } = await params
|
|
||||||
const library = getLibrary(libraryId)
|
|
||||||
if (!library) {
|
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (library.type !== 'comics') {
|
|
||||||
return NextResponse.json({ error: 'Only comics libraries support bulk rename' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
const { pattern, preview } = body as { pattern: string; preview?: boolean }
|
|
||||||
|
|
||||||
if (!pattern || typeof pattern !== 'string') {
|
|
||||||
return NextResponse.json({ error: 'Pattern is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate regex
|
|
||||||
let regex: RegExp
|
|
||||||
try {
|
|
||||||
regex = new RegExp(pattern, 'g')
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid regex pattern' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, title FROM media_items
|
|
||||||
WHERE library_id = ? AND item_type IN ('comic_series', 'comic_issue')`
|
|
||||||
)
|
|
||||||
.all(libraryId) as { item_key: string; title: string }[]
|
|
||||||
|
|
||||||
const changes: { itemKey: string; oldTitle: string; newTitle: string }[] = []
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
// Reset lastIndex since we reuse the regex with 'g' flag
|
|
||||||
regex.lastIndex = 0
|
|
||||||
const newTitle = row.title.replace(regex, '').trim()
|
|
||||||
if (newTitle && newTitle !== row.title) {
|
|
||||||
changes.push({ itemKey: row.item_key, oldTitle: row.title, newTitle })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preview) {
|
|
||||||
return NextResponse.json({ changes })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply
|
|
||||||
const stmt = db.prepare('UPDATE media_items SET title = ? WHERE item_key = ?')
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const c of changes) {
|
|
||||||
stmt.run(c.newTitle, c.itemKey)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return NextResponse.json({ updated: changes.length })
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { requireAdmin } from '@/lib/auth'
|
|
||||||
import { getLibrary } from '@/lib/libraries'
|
|
||||||
import { importMovieMetadata } from '@/lib/movie-metadata'
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const { pathname } = new URL(request.url)
|
|
||||||
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-movies
|
|
||||||
|
|
||||||
try {
|
|
||||||
const library = getLibrary(libraryId)
|
|
||||||
|
|
||||||
if (!library || library.type !== 'movies') {
|
|
||||||
return NextResponse.json({ error: 'Movies library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform full metadata import for all items
|
|
||||||
const result = await importMovieMetadata(library, true)
|
|
||||||
|
|
||||||
return NextResponse.json(result)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[import-metadata-movies]', err)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { requireAdmin } from '@/lib/auth'
|
|
||||||
import { getLibrary } from '@/lib/libraries'
|
|
||||||
import { importTvMetadata } from '@/lib/tv-metadata'
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const { pathname } = new URL(request.url)
|
|
||||||
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-tv
|
|
||||||
|
|
||||||
try {
|
|
||||||
const library = getLibrary(libraryId)
|
|
||||||
|
|
||||||
if (!library || library.type !== 'tv') {
|
|
||||||
return NextResponse.json({ error: 'TV library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform full metadata import for all items
|
|
||||||
const result = await importTvMetadata(library, true)
|
|
||||||
|
|
||||||
return NextResponse.json(result)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[import-metadata-tv]', err)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getLibrary } from '@/lib/libraries'
|
|
||||||
import { importComicMetadata } from '@/lib/comic-metadata'
|
|
||||||
import { requireAdmin } from '@/lib/auth'
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const library = getLibrary(id)
|
|
||||||
if (!library) {
|
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (library.type !== 'comics') {
|
|
||||||
return NextResponse.json({ error: 'Metadata import is only supported for comic libraries' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire-and-forget
|
|
||||||
void Promise.resolve().then(() => {
|
|
||||||
try {
|
|
||||||
importComicMetadata(library)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[import-metadata] Error importing metadata for "${library.name}":`, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return new NextResponse(null, { status: 202 })
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const libraries =
|
const libraries =
|
||||||
session.role === 'admin'
|
session.role === 'admin'
|
||||||
? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' }))
|
? getLibraries()
|
||||||
: getLibrariesForUser(session.userId, session.role)
|
: getLibrariesForUser(session.userId, session.role)
|
||||||
return NextResponse.json(libraries)
|
return NextResponse.json(libraries)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTypes: LibraryType[] = ['comics', 'games', 'mixed', 'movies', 'tv']
|
const validTypes: LibraryType[] = ['games', 'mixed', 'movies', 'tv']
|
||||||
if (!validTypes.includes(type as LibraryType)) {
|
if (!validTypes.includes(type as LibraryType)) {
|
||||||
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,46 +120,7 @@ export async function POST(request: NextRequest) {
|
|||||||
status: nfo.status ?? null,
|
status: nfo.status ?? null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year })
|
||||||
// Optionally also refresh every episode NFO in this series
|
|
||||||
let episodesUpdated = 0
|
|
||||||
const includeEpisodes = searchParams.get('includeEpisodes') === 'true'
|
|
||||||
if (includeEpisodes) {
|
|
||||||
type EpRow = { item_key: string; file_path: string | null; metadata: string | null }
|
|
||||||
const episodeRows = db
|
|
||||||
.prepare(`SELECT item_key, file_path, metadata FROM media_items WHERE item_type = 'tv_episode' AND item_key LIKE ?`)
|
|
||||||
.all(`${libraryId}:tv_episode:${encodedDirName}:%`) as EpRow[]
|
|
||||||
|
|
||||||
const updateEp = db.prepare(`
|
|
||||||
UPDATE media_items SET title = @title, plot = @plot, metadata = @metadata WHERE item_key = @item_key
|
|
||||||
`)
|
|
||||||
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const ep of episodeRows) {
|
|
||||||
if (!ep.file_path) continue
|
|
||||||
const epDir = path.join(libraryRoot, path.dirname(ep.file_path))
|
|
||||||
const baseName = path.basename(ep.file_path, path.extname(ep.file_path))
|
|
||||||
const epNfo = parseEpisodeNfo(path.join(epDir, `${baseName}.nfo`))
|
|
||||||
if (!epNfo) continue
|
|
||||||
const epMeta = ep.metadata ? JSON.parse(ep.metadata) : {}
|
|
||||||
updateEp.run({
|
|
||||||
item_key: ep.item_key,
|
|
||||||
title: epNfo.title ?? null,
|
|
||||||
plot: epNfo.plot ?? null,
|
|
||||||
metadata: JSON.stringify({
|
|
||||||
...epMeta,
|
|
||||||
episodeNumber: epNfo.episode ?? epMeta.episodeNumber ?? null,
|
|
||||||
seasonNumber: epNfo.season ?? epMeta.seasonNumber ?? null,
|
|
||||||
aired: epNfo.aired ?? null,
|
|
||||||
rating: epNfo.rating ?? null,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
episodesUpdated++
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year, episodesUpdated })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemType === 'tv_episode') {
|
if (itemType === 'tv_episode') {
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
|
||||||
import { getDb } from '@/lib/db'
|
|
||||||
|
|
||||||
function extractLibraryId(itemKey: string): string | null {
|
|
||||||
const colonIdx = itemKey.indexOf(':')
|
|
||||||
if (colonIdx === -1) return null
|
|
||||||
return itemKey.slice(0, colonIdx)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const itemKey = searchParams.get('itemKey')
|
|
||||||
if (!itemKey) {
|
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
const libraryId = extractLibraryId(itemKey)
|
|
||||||
if (!libraryId) {
|
|
||||||
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
|
||||||
}
|
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT user_rating FROM media_items WHERE item_key = ?')
|
|
||||||
.get(itemKey) as { user_rating: number | null } | undefined
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ userRating: row.user_rating ?? null })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
|
||||||
const body = await request.json()
|
|
||||||
const { itemKey, userRating } = body as { itemKey: string; userRating: number | null }
|
|
||||||
|
|
||||||
if (!itemKey) {
|
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (userRating !== null && (typeof userRating !== 'number' || !Number.isInteger(userRating) || userRating < 1 || userRating > 5)) {
|
|
||||||
return NextResponse.json({ error: 'userRating must be null or an integer 1–5' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryId = extractLibraryId(itemKey)
|
|
||||||
if (!libraryId) {
|
|
||||||
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
|
||||||
}
|
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const result = db
|
|
||||||
.prepare('UPDATE media_items SET user_rating = ? WHERE item_key = ?')
|
|
||||||
.run(userRating, itemKey)
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { deleteTagMapping } from '@/lib/comic-metadata'
|
|
||||||
import { requireAdmin } from '@/lib/auth'
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
try {
|
|
||||||
deleteTagMapping(id)
|
|
||||||
return new NextResponse(null, { status: 204 })
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to delete mapping'
|
|
||||||
return NextResponse.json({ error: message }, { status: 404 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { getTagMappingsForLibrary, createTagMapping } from '@/lib/comic-metadata'
|
|
||||||
import { requireAdmin } from '@/lib/auth'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
const libraryId = request.nextUrl.searchParams.get('libraryId')
|
|
||||||
if (!libraryId) {
|
|
||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappings = getTagMappingsForLibrary(libraryId)
|
|
||||||
return NextResponse.json(mappings)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await requireAdmin(request)
|
|
||||||
if (auth instanceof NextResponse) return auth
|
|
||||||
|
|
||||||
let body: { libraryId?: string; importedTagName?: string; tagId?: string }
|
|
||||||
try {
|
|
||||||
body = await request.json()
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { libraryId, importedTagName, tagId } = body
|
|
||||||
if (!libraryId || !importedTagName || !tagId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'libraryId, importedTagName, and tagId are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mapping = createTagMapping(libraryId, importedTagName, tagId)
|
|
||||||
return NextResponse.json(mapping, { status: 201 })
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to create mapping'
|
|
||||||
return NextResponse.json({ error: message }, { status: 400 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
|
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
|
||||||
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
function extractLibraryId(itemKey: string): string | null {
|
function extractLibraryId(itemKey: string): string | null {
|
||||||
const colonIdx = itemKey.indexOf(':')
|
const colonIdx = itemKey.indexOf(':')
|
||||||
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
||||||
}
|
}
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
addTagToItem(itemKey, tagId)
|
addTagToItem(itemKey, tagId)
|
||||||
@@ -60,7 +60,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
||||||
}
|
}
|
||||||
const auth = await requireLibraryWriteAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
removeTagFromItem(itemKey, tagId)
|
removeTagFromItem(itemKey, tagId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { updateCategory, deleteCategory, deleteCategoryForce, getTags, getCategories, mergeCategories } from '@/lib/tags'
|
import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
@@ -11,30 +11,9 @@ export async function PATCH(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { name, merge } = await request.json()
|
const { name } = await request.json()
|
||||||
|
const category = updateCategory(id, name)
|
||||||
try {
|
return NextResponse.json(category)
|
||||||
const category = updateCategory(id, name)
|
|
||||||
return NextResponse.json(category)
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as Error).message
|
|
||||||
if (!msg.includes('already exists')) throw err
|
|
||||||
|
|
||||||
// A category with this name already exists — find it
|
|
||||||
const trimmed = (name as string).trim()
|
|
||||||
const target = getCategories().find((c) => c.name.toLowerCase() === trimmed.toLowerCase())
|
|
||||||
if (!target) throw err
|
|
||||||
|
|
||||||
if (merge) {
|
|
||||||
mergeCategories(id, target.id)
|
|
||||||
return NextResponse.json(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: msg, conflict: true, targetCategoryId: target.id },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import fsPromises from 'fs/promises'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { getThumbnailPath, getCbzThumbnailPath } from '@/lib/thumbnails'
|
import { getThumbnailPath } from '@/lib/thumbnails'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { isCorruptZipError } from '@/lib/zip-utils'
|
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
|
||||||
function getMediaType(filePath: string): 'image' | 'video' | 'cbz' | null {
|
function getMediaType(filePath: string): 'image' | 'video' | null {
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video'
|
if (VIDEO_EXTENSIONS.has(ext)) return 'video'
|
||||||
if (ext === '.cbz') return 'cbz'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,13 +43,11 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const mediaType = getMediaType(filePath)
|
const mediaType = getMediaType(filePath)
|
||||||
if (!mediaType) {
|
if (!mediaType) {
|
||||||
return NextResponse.json({ error: 'Thumbnails are only supported for image, video, and CBZ files' }, { status: 400 })
|
return NextResponse.json({ error: 'Thumbnails are only supported for image and video files' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbnailPath = mediaType === 'cbz'
|
const thumbnailPath = await getThumbnailPath(filePath, libraryId, mediaType)
|
||||||
? await getCbzThumbnailPath(filePath, libraryId)
|
|
||||||
: await getThumbnailPath(filePath, libraryId, mediaType)
|
|
||||||
const stat = fs.statSync(thumbnailPath)
|
const stat = fs.statSync(thumbnailPath)
|
||||||
const stream = fs.createReadStream(thumbnailPath)
|
const stream = fs.createReadStream(thumbnailPath)
|
||||||
|
|
||||||
@@ -65,30 +60,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isCorruptZipError(err)) {
|
console.error(`Thumbnail generation failed for ${filePath}:`, err)
|
||||||
// Move the corrupt archive to the library's .trash folder so it is excluded
|
|
||||||
// from future scans and hidden from the UI.
|
|
||||||
const trashDir = path.join(root, '.trash')
|
|
||||||
const filename = path.basename(filePath)
|
|
||||||
let dest = path.join(trashDir, filename)
|
|
||||||
fsPromises.mkdir(trashDir, { recursive: true })
|
|
||||||
.then(async () => {
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
const ext = path.extname(filename)
|
|
||||||
dest = path.join(trashDir, `${path.basename(filename, ext)}_${Date.now()}${ext}`)
|
|
||||||
}
|
|
||||||
await fsPromises.rename(filePath, dest).catch(async (e: NodeJS.ErrnoException) => {
|
|
||||||
if (e.code === 'EXDEV') {
|
|
||||||
await fsPromises.copyFile(filePath, dest)
|
|
||||||
await fsPromises.unlink(filePath)
|
|
||||||
} else throw e
|
|
||||||
})
|
|
||||||
console.log(`[thumbnail] Moved corrupt archive to trash: ${path.relative(root, filePath)}`)
|
|
||||||
})
|
|
||||||
.catch((e) => console.warn(`[thumbnail] Could not move corrupt archive to trash:`, e))
|
|
||||||
} else {
|
|
||||||
console.error(`Thumbnail generation failed for ${filePath}:`, err)
|
|
||||||
}
|
|
||||||
return new NextResponse(null, { status: 404 })
|
return new NextResponse(null, { status: 404 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
import { getUserById, getLibraryPermissions, setLibraryPermissions, type LibraryPermission } from '@/lib/users'
|
import { getUserById, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users'
|
||||||
import { getLibraries } from '@/lib/libraries'
|
import { getLibraries } from '@/lib/libraries'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -17,8 +17,8 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = getLibraryPermissions(id)
|
const libraryIds = getPermittedLibraryIds(id)
|
||||||
return NextResponse.json({ permissions })
|
return NextResponse.json({ libraryIds })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -35,41 +35,24 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { permissions?: unknown }
|
let body: { libraryIds?: unknown }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(body.permissions)) {
|
if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) {
|
||||||
return NextResponse.json({ error: 'permissions must be an array' }, { status: 400 })
|
return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const validAccessLevels = new Set(['read', 'write'])
|
|
||||||
for (const item of body.permissions) {
|
|
||||||
if (
|
|
||||||
typeof item !== 'object' ||
|
|
||||||
item === null ||
|
|
||||||
typeof (item as Record<string, unknown>).libraryId !== 'string' ||
|
|
||||||
!validAccessLevels.has((item as Record<string, unknown>).accessLevel as string)
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Each permission must have libraryId (string) and accessLevel ("read" | "write")' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = body.permissions as LibraryPermission[]
|
|
||||||
|
|
||||||
const allLibraries = getLibraries()
|
const allLibraries = getLibraries()
|
||||||
const validIds = new Set(allLibraries.map((l) => l.id))
|
const validIds = new Set(allLibraries.map((l) => l.id))
|
||||||
const invalid = permissions.filter((p) => !validIds.has(p.libraryId)).map((p) => p.libraryId)
|
const invalid = body.libraryIds.filter((id) => !validIds.has(id))
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
|
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
setLibraryPermissions(id, permissions)
|
setLibraryPermissions(id, body.libraryIds)
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { getLibrary } from '@/lib/libraries'
|
import { getLibrary } from '@/lib/libraries'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { getServerSession } from '@/lib/auth'
|
import { getServerSession } from '@/lib/auth'
|
||||||
import { getLibraryAccessLevel } from '@/lib/users'
|
import { getPermittedLibraryIds } from '@/lib/users'
|
||||||
import ComicsView from '@/components/comics/ComicsView'
|
|
||||||
import GamesView from '@/components/games/GamesView'
|
import GamesView from '@/components/games/GamesView'
|
||||||
import MixedView from '@/components/mixed/MixedView'
|
import MixedView from '@/components/mixed/MixedView'
|
||||||
import MoviesView from '@/components/movies/MoviesView'
|
import MoviesView from '@/components/movies/MoviesView'
|
||||||
@@ -24,42 +23,32 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
|||||||
const library = getLibrary(id)
|
const library = getLibrary(id)
|
||||||
if (!library) notFound()
|
if (!library) notFound()
|
||||||
|
|
||||||
let readOnly = false
|
|
||||||
if (session.role !== 'admin') {
|
if (session.role !== 'admin') {
|
||||||
const accessLevel = getLibraryAccessLevel(session.userId, id)
|
const permitted = getPermittedLibraryIds(session.userId)
|
||||||
if (!accessLevel) notFound()
|
if (!permitted.includes(id)) notFound()
|
||||||
readOnly = accessLevel === 'read'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{library.type !== 'mixed' && (
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
Libraries
|
||||||
Libraries
|
</a>
|
||||||
</a>
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
{library.name}
|
||||||
{library.name}
|
</span>
|
||||||
</span>
|
{session.role === 'admin' && (
|
||||||
{session.role === 'admin' && (
|
<div className="ml-auto">
|
||||||
<div className="ml-auto">
|
<ScanLibraryButton libraryId={id} />
|
||||||
<ScanLibraryButton libraryId={id} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{library.type === 'mixed' && session.role === 'admin' && (
|
|
||||||
<div className="flex justify-end mb-2">
|
|
||||||
<ScanLibraryButton libraryId={id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{library.type === 'comics' && <ComicsView libraryId={id} readOnly={readOnly} />}
|
{library.type === 'games' && <GamesView libraryId={id} />}
|
||||||
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
|
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
||||||
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
|
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
||||||
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
|
{library.type === 'tv' && <TvView libraryId={id} />}
|
||||||
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,6 @@ interface AiSettings {
|
|||||||
promptExtract: string
|
promptExtract: string
|
||||||
promptTranslate: string
|
promptTranslate: string
|
||||||
maxRetries: number
|
maxRetries: number
|
||||||
maxTokensTag: number
|
|
||||||
maxTokensDescribe: number
|
|
||||||
maxTokensExtract: number
|
|
||||||
maxTokensTranslate: number
|
|
||||||
ocrMode: 'hybrid' | 'tesseract' | 'llm'
|
|
||||||
ocrLanguages: string
|
|
||||||
ocrConfidenceThreshold: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AiJob {
|
interface AiJob {
|
||||||
@@ -54,10 +47,6 @@ interface LibraryOverride {
|
|||||||
promptTagger: string
|
promptTagger: string
|
||||||
promptExtract: string
|
promptExtract: string
|
||||||
promptTranslate: string
|
promptTranslate: string
|
||||||
maxTokensTag: number | null
|
|
||||||
maxTokensDescribe: number | null
|
|
||||||
maxTokensExtract: number | null
|
|
||||||
maxTokensTranslate: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatElapsed(startedAt: number): string {
|
function formatElapsed(startedAt: number): string {
|
||||||
@@ -78,8 +67,6 @@ export default function AiTaggingPage() {
|
|||||||
enabled: false, preferredLanguage: 'English',
|
enabled: false, preferredLanguage: 'English',
|
||||||
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
maxTokensTag: 8192, maxTokensDescribe: 8192, maxTokensExtract: 8192, maxTokensTranslate: 8192,
|
|
||||||
ocrMode: 'hybrid', ocrLanguages: 'eng', ocrConfidenceThreshold: 70,
|
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -309,7 +296,7 @@ export default function AiTaggingPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string | number | null) => {
|
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string) => {
|
||||||
setLibraryOverrides((prev) => ({
|
setLibraryOverrides((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
|
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
|
||||||
@@ -557,25 +544,6 @@ export default function AiTaggingPage() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Tagging Max Tokens">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={settings.maxTokensTag}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((s) => ({ ...s, maxTokensTag: Math.max(1, parseInt(e.target.value) || 8192) }))
|
|
||||||
}
|
|
||||||
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Description Model">
|
<Field label="Description Model">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -593,25 +561,6 @@ export default function AiTaggingPage() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Description Max Tokens">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={settings.maxTokensDescribe}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((s) => ({ ...s, maxTokensDescribe: Math.max(1, parseInt(e.target.value) || 8192) }))
|
|
||||||
}
|
|
||||||
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Text Extraction Model">
|
<Field label="Text Extraction Model">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -629,91 +578,6 @@ export default function AiTaggingPage() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Text Extraction Max Tokens">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={settings.maxTokensExtract}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((s) => ({ ...s, maxTokensExtract: Math.max(1, parseInt(e.target.value) || 8192) }))
|
|
||||||
}
|
|
||||||
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="OCR Mode">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['hybrid', 'tesseract', 'llm'] as const).map((mode) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSettings((s) => ({ ...s, ocrMode: mode }))}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: settings.ocrMode === mode ? 'var(--accent)' : 'var(--surface)',
|
|
||||||
color: settings.ocrMode === mode ? '#fff' : 'var(--text-secondary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === 'hybrid' ? 'Hybrid' : mode === 'tesseract' ? 'Tesseract only' : 'LLM only'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Hybrid runs local OCR first and falls back to the LLM when confidence is low. Tesseract only never calls the LLM. LLM only uses the original behaviour.
|
|
||||||
</p>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="OCR Languages">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.ocrLanguages}
|
|
||||||
onChange={(e) => setSettings((s) => ({ ...s, ocrLanguages: e.target.value }))}
|
|
||||||
placeholder="eng"
|
|
||||||
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{`Tesseract language packs to use, joined with '+'. For Japanese manga use jpn+jpn_vert. Language data is downloaded automatically on first use.`}
|
|
||||||
</p>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="OCR Confidence Threshold">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={settings.ocrConfidenceThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((s) => ({ ...s, ocrConfidenceThreshold: Math.max(0, Math.min(100, parseInt(e.target.value) || 70)) }))
|
|
||||||
}
|
|
||||||
className="w-24 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
In hybrid mode, Tesseract results below this confidence score (0–100) fall back to the LLM. Default is 70.
|
|
||||||
</p>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Translation Model">
|
<Field label="Translation Model">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -731,25 +595,6 @@ export default function AiTaggingPage() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Translation Max Tokens">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={settings.maxTokensTranslate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((s) => ({ ...s, maxTokensTranslate: Math.max(1, parseInt(e.target.value) || 8192) }))
|
|
||||||
}
|
|
||||||
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Automatic Tagging">
|
<Field label="Automatic Tagging">
|
||||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
<div
|
<div
|
||||||
@@ -1045,7 +890,7 @@ export default function AiTaggingPage() {
|
|||||||
<Field key={field} label={label}>
|
<Field key={field} label={label}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={overrides[field] as string}
|
value={overrides[field]}
|
||||||
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
|
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
|
||||||
placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`}
|
placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`}
|
||||||
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
|
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
|
||||||
@@ -1061,39 +906,6 @@ export default function AiTaggingPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Max Tokens</p>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
['maxTokensTag', 'Tagging', 'maxTokensTag'] as const,
|
|
||||||
['maxTokensDescribe', 'Description', 'maxTokensDescribe'] as const,
|
|
||||||
['maxTokensExtract', 'Text Extraction', 'maxTokensExtract'] as const,
|
|
||||||
['maxTokensTranslate', 'Translation', 'maxTokensTranslate'] as const,
|
|
||||||
]
|
|
||||||
).map(([field, label, globalField]) => (
|
|
||||||
<Field key={field} label={label}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={overrides[field] ?? ''}
|
|
||||||
placeholder={`Leave blank to use global default (${settings[globalField]})`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const raw = e.target.value
|
|
||||||
updateLibraryOverride(lib.id, field, raw === '' ? null : Math.max(1, parseInt(raw) || 1))
|
|
||||||
}}
|
|
||||||
className="w-40 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
|
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
|
||||||
{(
|
{(
|
||||||
@@ -1107,7 +919,7 @@ export default function AiTaggingPage() {
|
|||||||
<Field key={field} label={label}>
|
<Field key={field} label={label}>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={overrides[field] as string}
|
value={overrides[field]}
|
||||||
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
|
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
|
||||||
placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'}
|
placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'}
|
||||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||||
@@ -1198,7 +1010,6 @@ function emptyOverride(): LibraryOverride {
|
|||||||
return {
|
return {
|
||||||
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
||||||
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||||
maxTokensTag: null, maxTokensDescribe: null, maxTokensExtract: null, maxTokensTranslate: null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Image from 'next/image'
|
|||||||
import type { Library, LibraryType } from '@/types'
|
import type { Library, LibraryType } from '@/types'
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
comics: '📚',
|
|
||||||
games: '🎮',
|
games: '🎮',
|
||||||
mixed: '🗂️',
|
mixed: '🗂️',
|
||||||
movies: '🎬',
|
movies: '🎬',
|
||||||
@@ -13,7 +12,6 @@ const TYPE_ICONS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<LibraryType, string> = {
|
const TYPE_LABELS: Record<LibraryType, string> = {
|
||||||
comics: 'Comics / Manga',
|
|
||||||
games: 'Games',
|
games: 'Games',
|
||||||
mixed: 'Mixed Media',
|
mixed: 'Mixed Media',
|
||||||
movies: 'Movies',
|
movies: 'Movies',
|
||||||
@@ -22,7 +20,7 @@ const TYPE_LABELS: Record<LibraryType, string> = {
|
|||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ManagePage() {
|
export default function ManagePage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -107,12 +105,8 @@ function LibraryRow({
|
|||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [removing, setRemoving] = useState(false)
|
const [removing, setRemoving] = useState(false)
|
||||||
const [uploadingCover, setUploadingCover] = useState(false)
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle')
|
|
||||||
const [showBulkRename, setShowBulkRename] = useState(false)
|
|
||||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [showImportWarning, setShowImportWarning] = useState(false)
|
|
||||||
const [importingMetadata, setImportingMetadata] = useState(false)
|
|
||||||
|
|
||||||
const handleRemoveClick = () => {
|
const handleRemoveClick = () => {
|
||||||
if (!confirming) {
|
if (!confirming) {
|
||||||
@@ -127,26 +121,6 @@ function LibraryRow({
|
|||||||
.catch(() => setRemoving(false))
|
.catch(() => setRemoving(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportMetadata = async () => {
|
|
||||||
setImportingMetadata(true)
|
|
||||||
setShowImportWarning(false)
|
|
||||||
try {
|
|
||||||
const endpoint =
|
|
||||||
library.type === 'tv'
|
|
||||||
? `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-tv`
|
|
||||||
: `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-movies`
|
|
||||||
const res = await fetch(endpoint, { method: 'POST' })
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
console.log(`[manage] Imported metadata: ${data.imported} items, skipped ${data.skipped}`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[manage] Error importing metadata:', err)
|
|
||||||
} finally {
|
|
||||||
setImportingMetadata(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
@@ -233,57 +207,6 @@ function LibraryRow({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{library.type === 'comics' && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setImporting('running')
|
|
||||||
fetch(`/api/libraries/${encodeURIComponent(library.id)}/import-metadata`, { method: 'POST' })
|
|
||||||
.then(() => {
|
|
||||||
setImporting('done')
|
|
||||||
setTimeout(() => setImporting('idle'), 3000)
|
|
||||||
})
|
|
||||||
.catch(() => setImporting('idle'))
|
|
||||||
}}
|
|
||||||
disabled={importing === 'running'}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowBulkRename(true)}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
>
|
|
||||||
Bulk Rename
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(library.type === 'tv' || library.type === 'movies') && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowImportWarning(true)}
|
|
||||||
disabled={importingMetadata}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{importingMetadata ? 'Importing…' : 'Import Metadata'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{library.coverExt && (
|
{library.coverExt && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveCover}
|
onClick={handleRemoveCover}
|
||||||
@@ -330,216 +253,6 @@ function LibraryRow({
|
|||||||
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
|
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBulkRename && (
|
|
||||||
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
|
|
||||||
)}
|
|
||||||
{showImportWarning && (library.type === 'tv' || library.type === 'movies') && (
|
|
||||||
<ImportWarningModal
|
|
||||||
libraryType={library.type}
|
|
||||||
onConfirm={handleImportMetadata}
|
|
||||||
onCancel={() => setShowImportWarning(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Bulk Rename Modal ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function BulkRenameModal({ libraryId, onClose }: { libraryId: string; onClose: () => void }) {
|
|
||||||
const [pattern, setPattern] = useState('')
|
|
||||||
const [preview, setPreview] = useState<{ itemKey: string; oldTitle: string; newTitle: string }[] | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [applying, setApplying] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [result, setResult] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handlePreview = async () => {
|
|
||||||
if (!pattern.trim()) return
|
|
||||||
setError(null)
|
|
||||||
setPreview(null)
|
|
||||||
setResult(null)
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pattern: pattern.trim(), preview: true }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(data.error ?? 'Failed to preview')
|
|
||||||
} else {
|
|
||||||
setPreview(data.changes ?? [])
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Network error')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApply = async () => {
|
|
||||||
if (!pattern.trim()) return
|
|
||||||
setError(null)
|
|
||||||
setApplying(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pattern: pattern.trim() }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(data.error ?? 'Failed to apply')
|
|
||||||
} else {
|
|
||||||
setResult(`Updated ${data.updated} title${data.updated === 1 ? '' : 's'}`)
|
|
||||||
setPreview(null)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Network error')
|
|
||||||
} finally {
|
|
||||||
setApplying(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
|
|
||||||
style={{ borderBottom: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>Bulk Rename</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Enter a regex pattern to remove from comic titles
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="px-5 py-4 overflow-y-auto flex-1">
|
|
||||||
{/* Pattern input */}
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={pattern}
|
|
||||||
onChange={(e) => setPattern(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handlePreview() }}
|
|
||||||
placeholder="e.g. \[English\]|\{doujin-moe\.us\}"
|
|
||||||
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none font-mono"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handlePreview}
|
|
||||||
disabled={!pattern.trim() || loading}
|
|
||||||
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{loading ? 'Loading…' : 'Preview'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p
|
|
||||||
className="text-xs mb-3 px-3 py-2 rounded-lg"
|
|
||||||
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<p
|
|
||||||
className="text-xs mb-3 px-3 py-2 rounded-lg"
|
|
||||||
style={{ backgroundColor: '#14532d33', color: '#86efac' }}
|
|
||||||
>
|
|
||||||
{result}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview list */}
|
|
||||||
{preview !== null && (
|
|
||||||
preview.length === 0 ? (
|
|
||||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
No titles match this pattern.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs mb-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{preview.length} title{preview.length === 1 ? '' : 's'} will be updated:
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="rounded-lg border divide-y overflow-hidden"
|
|
||||||
style={{ borderColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
{preview.map((c) => (
|
|
||||||
<div key={c.itemKey} className="px-3 py-2">
|
|
||||||
<p className="text-xs line-through" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{c.oldTitle}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{c.newTitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{preview && preview.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-2 px-5 py-3 flex-shrink-0"
|
|
||||||
style={{ borderTop: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
disabled={applying}
|
|
||||||
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
|
||||||
>
|
|
||||||
{applying ? 'Applying…' : `Apply to ${preview.length} title${preview.length === 1 ? '' : 's'}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -621,7 +334,6 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
|
|||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
||||||
>
|
>
|
||||||
<option value="comics">Comics / Manga</option>
|
|
||||||
<option value="games">Games</option>
|
<option value="games">Games</option>
|
||||||
<option value="mixed">Mixed Media</option>
|
<option value="mixed">Mixed Media</option>
|
||||||
<option value="movies">Movies</option>
|
<option value="movies">Movies</option>
|
||||||
@@ -703,57 +415,3 @@ function LoadingRows() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportWarningModal({
|
|
||||||
libraryType,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
libraryType: 'tv' | 'movies'
|
|
||||||
onConfirm: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
}) {
|
|
||||||
const label = libraryType === 'tv' ? 'TV' : 'Movie'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-2xl border p-5"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Import {label} Metadata
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm mb-5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Full metadata import will refresh metadata for ALL items in this library, overwriting any
|
|
||||||
existing data. Continue?
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ManagePage
|
|
||||||
|
|||||||
@@ -1,865 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
||||||
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
|
|
||||||
|
|
||||||
export default function TagMappingsPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const libraryId = params.id as string
|
|
||||||
|
|
||||||
const [library, setLibrary] = useState<Library | null>(null)
|
|
||||||
const [importedTags, setImportedTags] = useState<ImportedTag[]>([])
|
|
||||||
const [mappings, setMappings] = useState<TagMapping[]>([])
|
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
|
||||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [prefixMappings, setPrefixMappings] = useState<Record<string, string>>({})
|
|
||||||
const [ignoredTags, setIgnoredTags] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// Load prefix mappings and ignored tags from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(`prefix-mappings-${libraryId}`)
|
|
||||||
if (stored) setPrefixMappings(JSON.parse(stored))
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(`ignored-tags-${libraryId}`)
|
|
||||||
if (stored) setIgnoredTags(new Set(JSON.parse(stored)))
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [libraryId])
|
|
||||||
|
|
||||||
const updatePrefixMappings = useCallback((next: Record<string, string>) => {
|
|
||||||
setPrefixMappings(next)
|
|
||||||
try {
|
|
||||||
localStorage.setItem(`prefix-mappings-${libraryId}`, JSON.stringify(next))
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [libraryId])
|
|
||||||
|
|
||||||
const updateIgnoredTags = useCallback((next: Set<string>) => {
|
|
||||||
setIgnoredTags(next)
|
|
||||||
try {
|
|
||||||
localStorage.setItem(`ignored-tags-${libraryId}`, JSON.stringify([...next]))
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [libraryId])
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
Promise.all([
|
|
||||||
fetch(`/api/imported-tags?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
|
|
||||||
fetch(`/api/tag-mappings?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
|
|
||||||
fetch('/api/tags/items').then((r) => r.json()),
|
|
||||||
fetch('/api/tags/categories').then((r) => r.json()),
|
|
||||||
fetch('/api/libraries').then((r) => r.json()),
|
|
||||||
])
|
|
||||||
.then(([imported, maps, tgs, cats, libs]: [ImportedTag[], TagMapping[], Tag[], TagCategory[], Library[]]) => {
|
|
||||||
setImportedTags(imported)
|
|
||||||
setMappings(maps)
|
|
||||||
setTags(tgs)
|
|
||||||
setCategories(cats)
|
|
||||||
setLibrary(libs.find((l) => l.id === libraryId) ?? null)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [libraryId])
|
|
||||||
|
|
||||||
const tagsByCategory = useMemo(() => (
|
|
||||||
categories
|
|
||||||
.map((cat) => ({
|
|
||||||
category: cat,
|
|
||||||
tags: tags.filter((t) => t.categoryId === cat.id),
|
|
||||||
}))
|
|
||||||
.filter((g) => g.tags.length > 0)
|
|
||||||
), [categories, tags])
|
|
||||||
|
|
||||||
const visibleTags = useMemo(() => importedTags.filter((t) => !ignoredTags.has(t.name)), [importedTags, ignoredTags])
|
|
||||||
const hiddenTags = useMemo(() => importedTags.filter((t) => ignoredTags.has(t.name)), [importedTags, ignoredTags])
|
|
||||||
|
|
||||||
const handleIgnoreTag = useCallback((name: string) => {
|
|
||||||
updateIgnoredTags(new Set([...ignoredTags, name]))
|
|
||||||
}, [ignoredTags, updateIgnoredTags])
|
|
||||||
|
|
||||||
const handleUnignoreTag = useCallback((name: string) => {
|
|
||||||
const next = new Set(ignoredTags)
|
|
||||||
next.delete(name)
|
|
||||||
updateIgnoredTags(next)
|
|
||||||
}, [ignoredTags, updateIgnoredTags])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<a
|
|
||||||
href="/manage/tags"
|
|
||||||
className="text-sm no-underline transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
>
|
|
||||||
← Tags
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Tag Mappings{library ? ` — ${library.name}` : ''}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Map imported tags from ComicInfo.xml files to your tag categories.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Section title="Unmapped Tags">
|
|
||||||
<LoadingRows />
|
|
||||||
</Section>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PrefixMappingsSection
|
|
||||||
categories={categories}
|
|
||||||
importedTags={importedTags}
|
|
||||||
prefixMappings={prefixMappings}
|
|
||||||
onUpdate={updatePrefixMappings}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Section title="Unmapped Tags">
|
|
||||||
{visibleTags.length === 0 ? (
|
|
||||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{importedTags.length === 0
|
|
||||||
? 'No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found.'
|
|
||||||
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<VirtualizedImportedTagRows
|
|
||||||
tags={visibleTags}
|
|
||||||
libraryId={libraryId}
|
|
||||||
tagsByCategory={tagsByCategory}
|
|
||||||
categories={categories}
|
|
||||||
prefixMappings={prefixMappings}
|
|
||||||
onMapped={refresh}
|
|
||||||
onIgnore={handleIgnoreTag}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{hiddenTags.length > 0 && (
|
|
||||||
<IgnoredTagsSection
|
|
||||||
tags={hiddenTags}
|
|
||||||
onUnignore={handleUnignoreTag}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section title="Saved Mappings">
|
|
||||||
{mappings.length === 0 ? (
|
|
||||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
No saved mappings yet. Map imported tags above to create persistent mappings.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
|
||||||
{mappings.map((m) => (
|
|
||||||
<MappingRow key={m.id} mapping={m} onDeleted={refresh} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Prefix Mappings Section ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function PrefixMappingsSection({
|
|
||||||
categories,
|
|
||||||
importedTags,
|
|
||||||
prefixMappings,
|
|
||||||
onUpdate,
|
|
||||||
}: {
|
|
||||||
categories: TagCategory[]
|
|
||||||
importedTags: ImportedTag[]
|
|
||||||
prefixMappings: Record<string, string>
|
|
||||||
onUpdate: (next: Record<string, string>) => void
|
|
||||||
}) {
|
|
||||||
const [newPrefix, setNewPrefix] = useState('')
|
|
||||||
const [newCategoryId, setNewCategoryId] = useState('')
|
|
||||||
|
|
||||||
// Detect prefixes from imported tags that aren't yet mapped
|
|
||||||
const detectedPrefixes = Array.from(
|
|
||||||
new Set(
|
|
||||||
importedTags
|
|
||||||
.map((t) => {
|
|
||||||
const idx = t.name.indexOf(': ')
|
|
||||||
return idx > 0 ? t.name.slice(0, idx).trim().toLowerCase() : null
|
|
||||||
})
|
|
||||||
.filter((p): p is string => p !== null)
|
|
||||||
)
|
|
||||||
).filter((p) => !(p in prefixMappings)).sort()
|
|
||||||
|
|
||||||
const catMap = new Map(categories.map((c) => [c.id, c.name]))
|
|
||||||
const entries = Object.entries(prefixMappings)
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
const key = newPrefix.trim().toLowerCase()
|
|
||||||
if (!key || !newCategoryId) return
|
|
||||||
onUpdate({ ...prefixMappings, [key]: newCategoryId })
|
|
||||||
setNewPrefix('')
|
|
||||||
setNewCategoryId('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemove = (key: string) => {
|
|
||||||
const next = { ...prefixMappings }
|
|
||||||
delete next[key]
|
|
||||||
onUpdate(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section title="Prefix Mappings">
|
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Map tag prefixes (e.g. "language" in "language: english") to categories.
|
|
||||||
When creating a new tag, the category and name will auto-fill.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Existing mappings */}
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<div className="divide-y mb-3" style={{ borderColor: 'var(--border)' }}>
|
|
||||||
{entries.map(([prefix, catId]) => (
|
|
||||||
<div key={prefix} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-mono"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
{prefix}:
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>→</span>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{catMap.get(catId) ?? catId}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemove(prefix)}
|
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add row */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newPrefix}
|
|
||||||
onChange={(e) => setNewPrefix(e.target.value)}
|
|
||||||
placeholder="prefix"
|
|
||||||
className="rounded-lg px-2 py-1.5 text-xs font-mono outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
width: 100,
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd() }}
|
|
||||||
/>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>→</span>
|
|
||||||
<select
|
|
||||||
value={newCategoryId}
|
|
||||||
onChange={(e) => setNewCategoryId(e.target.value)}
|
|
||||||
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
minWidth: 130,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Category…</option>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={!newPrefix.trim() || !newCategoryId}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Suggestions */}
|
|
||||||
{detectedPrefixes.length > 0 && (
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Detected:</span>
|
|
||||||
{detectedPrefixes.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
onClick={() => setNewPrefix(p)}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function VirtualizedImportedTagRows({
|
|
||||||
tags,
|
|
||||||
libraryId,
|
|
||||||
tagsByCategory,
|
|
||||||
categories,
|
|
||||||
prefixMappings,
|
|
||||||
onMapped,
|
|
||||||
onIgnore,
|
|
||||||
}: {
|
|
||||||
tags: ImportedTag[]
|
|
||||||
libraryId: string
|
|
||||||
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
|
|
||||||
categories: TagCategory[]
|
|
||||||
prefixMappings: Record<string, string>
|
|
||||||
onMapped: () => void
|
|
||||||
onIgnore: (name: string) => void
|
|
||||||
}) {
|
|
||||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
|
||||||
count: tags.length,
|
|
||||||
getScrollElement: () => parentRef.current,
|
|
||||||
estimateSize: () => 56,
|
|
||||||
overscan: 8,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={parentRef} className="max-h-[560px] overflow-auto">
|
|
||||||
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
|
|
||||||
{rowVirtualizer.getVirtualItems().map((row) => {
|
|
||||||
const importedTag = tags[row.index]
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={importedTag.name}
|
|
||||||
ref={rowVirtualizer.measureElement}
|
|
||||||
data-index={row.index}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
transform: `translateY(${row.start}px)`,
|
|
||||||
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ImportedTagRow
|
|
||||||
importedTag={importedTag}
|
|
||||||
libraryId={libraryId}
|
|
||||||
tagsByCategory={tagsByCategory}
|
|
||||||
categories={categories}
|
|
||||||
prefixMappings={prefixMappings}
|
|
||||||
onMapped={onMapped}
|
|
||||||
onIgnore={() => onIgnore(importedTag.name)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ImportedTagRow = memo(function ImportedTagRow({
|
|
||||||
importedTag,
|
|
||||||
libraryId,
|
|
||||||
tagsByCategory,
|
|
||||||
categories,
|
|
||||||
prefixMappings,
|
|
||||||
onMapped,
|
|
||||||
onIgnore,
|
|
||||||
}: {
|
|
||||||
importedTag: ImportedTag
|
|
||||||
libraryId: string
|
|
||||||
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
|
|
||||||
categories: TagCategory[]
|
|
||||||
prefixMappings: Record<string, string>
|
|
||||||
onMapped: () => void
|
|
||||||
onIgnore: () => void
|
|
||||||
}) {
|
|
||||||
// Auto-match: if prefix mapping exists, find a tag in that category matching the stripped name
|
|
||||||
const autoMatchedTagId = useMemo(() => {
|
|
||||||
const colonIdx = importedTag.name.indexOf(': ')
|
|
||||||
if (colonIdx <= 0) return ''
|
|
||||||
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
|
|
||||||
const mappedCategoryId = prefixMappings[prefix]
|
|
||||||
if (!mappedCategoryId) return ''
|
|
||||||
const strippedName = importedTag.name.slice(colonIdx + 2).trim().toLowerCase()
|
|
||||||
const group = tagsByCategory.find((g) => g.category.id === mappedCategoryId)
|
|
||||||
const match = group?.tags.find((t) => t.name.toLowerCase() === strippedName)
|
|
||||||
return match?.id ?? ''
|
|
||||||
}, [importedTag.name, prefixMappings, tagsByCategory])
|
|
||||||
|
|
||||||
const [selectedTagId, setSelectedTagId] = useState('')
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const [newTagName, setNewTagName] = useState(importedTag.name)
|
|
||||||
const [newTagCategoryId, setNewTagCategoryId] = useState('')
|
|
||||||
const [creatingTag, setCreatingTag] = useState(false)
|
|
||||||
|
|
||||||
// Apply auto-match when it changes (e.g. prefix mappings updated)
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoMatchedTagId) setSelectedTagId(autoMatchedTagId)
|
|
||||||
}, [autoMatchedTagId])
|
|
||||||
|
|
||||||
const startCreating = () => {
|
|
||||||
// Apply prefix mapping defaults if the imported tag has a colon prefix
|
|
||||||
const colonIdx = importedTag.name.indexOf(': ')
|
|
||||||
if (colonIdx > 0) {
|
|
||||||
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
|
|
||||||
const mappedCategoryId = prefixMappings[prefix]
|
|
||||||
if (mappedCategoryId) {
|
|
||||||
setNewTagCategoryId(mappedCategoryId)
|
|
||||||
setNewTagName(importedTag.name.slice(colonIdx + 2).trim())
|
|
||||||
setCreating(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setNewTagName(importedTag.name)
|
|
||||||
setNewTagCategoryId('')
|
|
||||||
setCreating(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMap = async () => {
|
|
||||||
if (!selectedTagId) return
|
|
||||||
setError(null)
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/tag-mappings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
libraryId,
|
|
||||||
importedTagName: importedTag.name,
|
|
||||||
tagId: selectedTagId,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setError(data.error ?? 'Failed to save mapping')
|
|
||||||
setSaving(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(false)
|
|
||||||
setSelectedTagId('')
|
|
||||||
onMapped()
|
|
||||||
} catch {
|
|
||||||
setError('Network error')
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateAndMap = async () => {
|
|
||||||
if (!newTagName.trim() || !newTagCategoryId) return
|
|
||||||
setError(null)
|
|
||||||
setCreatingTag(true)
|
|
||||||
try {
|
|
||||||
const createRes = await fetch('/api/tags/items', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: newTagName.trim(), categoryId: newTagCategoryId }),
|
|
||||||
})
|
|
||||||
if (!createRes.ok) {
|
|
||||||
const data = await createRes.json()
|
|
||||||
setError(data.error ?? 'Failed to create tag')
|
|
||||||
setCreatingTag(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const newTag = await createRes.json()
|
|
||||||
|
|
||||||
const mapRes = await fetch('/api/tag-mappings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
libraryId,
|
|
||||||
importedTagName: importedTag.name,
|
|
||||||
tagId: newTag.id,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!mapRes.ok) {
|
|
||||||
const data = await mapRes.json()
|
|
||||||
setError(data.error ?? 'Failed to save mapping')
|
|
||||||
setCreatingTag(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCreatingTag(false)
|
|
||||||
setCreating(false)
|
|
||||||
setNewTagName(importedTag.name)
|
|
||||||
setNewTagCategoryId('')
|
|
||||||
onMapped()
|
|
||||||
} catch {
|
|
||||||
setError('Network error')
|
|
||||||
setCreatingTag(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-3 first:pt-0 last:pb-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Left: imported tag name + item count */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
{importedTag.name}
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
({importedTag.itemCount})
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!creating ? (
|
|
||||||
<>
|
|
||||||
{/* Right: tag picker + map button + new button */}
|
|
||||||
<select
|
|
||||||
value={selectedTagId}
|
|
||||||
onChange={(e) => setSelectedTagId(e.target.value)}
|
|
||||||
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
minWidth: 160,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Select tag…</option>
|
|
||||||
{tagsByCategory.map((group) => (
|
|
||||||
<optgroup key={group.category.id} label={group.category.name}>
|
|
||||||
{group.tags.map((tag) => (
|
|
||||||
<option key={tag.id} value={tag.id}>
|
|
||||||
{tag.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleMap}
|
|
||||||
disabled={!selectedTagId || saving}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{saving ? 'Mapping…' : 'Map'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={startCreating}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
title="Create a new tag and map it"
|
|
||||||
>
|
|
||||||
+ New
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onIgnore}
|
|
||||||
className="text-xs px-2 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
title="Hide this tag"
|
|
||||||
>
|
|
||||||
Ignore
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Inline create: category picker + name input + create & map button */}
|
|
||||||
<select
|
|
||||||
value={newTagCategoryId}
|
|
||||||
onChange={(e) => setNewTagCategoryId(e.target.value)}
|
|
||||||
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
minWidth: 120,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Category…</option>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<option key={cat.id} value={cat.id}>
|
|
||||||
{cat.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newTagName}
|
|
||||||
onChange={(e) => setNewTagName(e.target.value)}
|
|
||||||
placeholder="Tag name"
|
|
||||||
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
width: 120,
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleCreateAndMap()
|
|
||||||
if (e.key === 'Escape') setCreating(false)
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCreateAndMap}
|
|
||||||
disabled={!newTagName.trim() || !newTagCategoryId || creatingTag}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{creatingTag ? 'Creating…' : 'Create & Map'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setCreating(false)}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs mt-1.5 px-3 py-1 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function IgnoredTagsSection({
|
|
||||||
tags,
|
|
||||||
onUnignore,
|
|
||||||
}: {
|
|
||||||
tags: ImportedTag[]
|
|
||||||
onUnignore: (name: string) => void
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
|
||||||
count: tags.length,
|
|
||||||
getScrollElement: () => parentRef.current,
|
|
||||||
estimateSize: () => 44,
|
|
||||||
overscan: 8,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-10">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="flex items-center gap-1.5 mb-3 group"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
<span className="text-xs transition-transform" style={{ display: 'inline-block', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
|
||||||
Ignored Tags ({tags.length})
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div
|
|
||||||
className="rounded-xl border"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
|
||||||
>
|
|
||||||
<div ref={parentRef} className="px-5 py-4 max-h-[360px] overflow-auto">
|
|
||||||
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
|
|
||||||
{rowVirtualizer.getVirtualItems().map((row) => {
|
|
||||||
const t = tags[row.index]
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={t.name}
|
|
||||||
ref={rowVirtualizer.measureElement}
|
|
||||||
data-index={row.index}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
transform: `translateY(${row.start}px)`,
|
|
||||||
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-3 py-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{t.name}
|
|
||||||
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={() => onUnignore(t.name)}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
>
|
|
||||||
Unignore
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mapping Row ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) {
|
|
||||||
const [confirming, setConfirming] = useState(false)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
const handleDeleteClick = () => {
|
|
||||||
if (!confirming) {
|
|
||||||
setConfirming(true)
|
|
||||||
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
|
||||||
setDeleting(true)
|
|
||||||
fetch(`/api/tag-mappings/${encodeURIComponent(mapping.id)}`, { method: 'DELETE' })
|
|
||||||
.then(() => onDeleted())
|
|
||||||
.catch(() => setDeleting(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
{mapping.importedTagName}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>→</span>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{mapping.categoryName}: {mapping.tagName}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1" />
|
|
||||||
{confirming && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
|
||||||
setConfirming(false)
|
|
||||||
}}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleDeleteClick}
|
|
||||||
disabled={deleting}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
|
|
||||||
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!confirming) {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!confirming) {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{deleting ? 'Deleting…' : confirming ? 'Confirm?' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-10">
|
|
||||||
<h2
|
|
||||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
className="rounded-xl border"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
|
||||||
>
|
|
||||||
<div className="px-5 py-4">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingRows() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{[70, 50, 85].map((w) => (
|
|
||||||
<div key={w} className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="h-4 rounded animate-pulse"
|
|
||||||
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import type { Tag, TagCategory, Library, ImportedTag } from '@/types'
|
import type { Tag, TagCategory } from '@/types'
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,8 +62,6 @@ export default function ManageTagsPage() {
|
|||||||
<Section title="Add a Category">
|
<Section title="Add a Category">
|
||||||
<AddCategoryForm onAdded={refresh} />
|
<AddCategoryForm onAdded={refresh} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<ImportedTagMappingsSection />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,13 +83,11 @@ function CategoryBlock({
|
|||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [mergeConflict, setMergeConflict] = useState<{ name: string } | null>(null)
|
|
||||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const handleRename = async (e: React.FormEvent) => {
|
const handleRename = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
setMergeConflict(null)
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
||||||
@@ -100,35 +96,8 @@ function CategoryBlock({
|
|||||||
body: JSON.stringify({ name: editName }),
|
body: JSON.stringify({ name: editName }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 409 && data.conflict) {
|
|
||||||
setMergeConflict({ name: editName.trim() })
|
|
||||||
setSaving(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError(data.error); setSaving(false); return
|
|
||||||
}
|
|
||||||
setEditing(false)
|
|
||||||
onChanged()
|
|
||||||
} catch {
|
|
||||||
setError('Network error.')
|
|
||||||
}
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMerge = async () => {
|
|
||||||
setError(null)
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: editName, merge: true }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) { setError(data.error); setSaving(false); return }
|
if (!res.ok) { setError(data.error); setSaving(false); return }
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
setMergeConflict(null)
|
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error.')
|
setError('Network error.')
|
||||||
@@ -187,7 +156,7 @@ function CategoryBlock({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditing(false); setEditName(category.name); setError(null); setMergeConflict(null) }}
|
onClick={() => { setEditing(false); setEditName(category.name); setError(null) }}
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
@@ -259,32 +228,6 @@ function CategoryBlock({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mergeConflict && (
|
|
||||||
<div className="mb-3 px-3 py-2 rounded-lg text-xs" style={{ backgroundColor: '#78350f33', color: '#fbbf24' }}>
|
|
||||||
<p className="mb-2">
|
|
||||||
A category named “{mergeConflict.name}” already exists. This will merge all tags from
|
|
||||||
“{category.name}” into it. Tags with the same name will be combined.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleMerge}
|
|
||||||
disabled={saving}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: '#b45309', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{saving ? 'Merging…' : 'Merge'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMergeConflict(null)}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags list */}
|
{/* Tags list */}
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
@@ -537,117 +480,6 @@ function AddCategoryForm({ onAdded }: { onAdded: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Imported Tag Mappings Section ────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ImportedTagMappingsSection() {
|
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
|
||||||
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
setError(null)
|
|
||||||
const libsRes = await fetch('/api/libraries')
|
|
||||||
const libsJson = await libsRes.json()
|
|
||||||
if (!Array.isArray(libsJson)) {
|
|
||||||
throw new Error('Failed to load libraries')
|
|
||||||
}
|
|
||||||
|
|
||||||
const comicLibs = libsJson.filter((l): l is Library => l?.type === 'comics')
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
setLibraries(comicLibs)
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
if (comicLibs.length === 0) return
|
|
||||||
|
|
||||||
const settled = await Promise.allSettled(
|
|
||||||
comicLibs.map(async (lib) => {
|
|
||||||
const res = await fetch(`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`)
|
|
||||||
if (!res.ok) return { libraryId: lib.id, count: 0 }
|
|
||||||
const json = await res.json()
|
|
||||||
const count = Array.isArray(json) ? json.length : 0
|
|
||||||
return { libraryId: lib.id, count }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
const counts: Record<string, number> = {}
|
|
||||||
for (const result of settled) {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
counts[result.value.libraryId] = result.value.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTagCounts(counts)
|
|
||||||
} catch {
|
|
||||||
if (cancelled) return
|
|
||||||
setError('Could not load imported tag mappings right now.')
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Section title="Imported Tag Mappings">
|
|
||||||
<LoadingRows />
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (libraries.length === 0) {
|
|
||||||
return (
|
|
||||||
<Section title="Imported Tag Mappings">
|
|
||||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
No comic libraries configured. Add a comic library to import tags from ComicInfo.xml files.
|
|
||||||
</p>
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section title="Imported Tag Mappings">
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs mb-3 px-3 py-1.5 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
|
||||||
{libraries.map((lib) => (
|
|
||||||
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
|
||||||
<span className="flex-1 font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{lib.name}
|
|
||||||
<span className="ml-2 font-normal text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{tagCounts[lib.id] ?? 0} imported tag{(tagCounts[lib.id] ?? 0) === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={`/manage/tags/mappings/${encodeURIComponent(lib.id)}`}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors no-underline"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
|
||||||
>
|
|
||||||
Manage Mappings
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -216,39 +216,32 @@ function UserRow({
|
|||||||
|
|
||||||
// ─── Permissions Panel ────────────────────────────────────────────────────────
|
// ─── Permissions Panel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type AccessLevel = 'none' | 'read' | 'write'
|
|
||||||
|
|
||||||
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
|
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
|
||||||
const [levels, setLevels] = useState<Record<string, AccessLevel>>({})
|
const [permitted, setPermitted] = useState<string[]>([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
|
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => {
|
.then((data: { libraryIds: string[] }) => {
|
||||||
const map: Record<string, AccessLevel> = {}
|
setPermitted(data.libraryIds)
|
||||||
for (const p of data.permissions) {
|
|
||||||
map[p.libraryId] = p.accessLevel
|
|
||||||
}
|
|
||||||
setLevels(map)
|
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
})
|
})
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
const setLevel = (libraryId: string, level: AccessLevel) => {
|
const toggle = (libraryId: string) => {
|
||||||
setLevels((prev) => ({ ...prev, [libraryId]: level }))
|
setPermitted((prev) =>
|
||||||
|
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const permissions = Object.entries(levels)
|
|
||||||
.filter(([, level]) => level !== 'none')
|
|
||||||
.map(([libraryId, accessLevel]) => ({ libraryId, accessLevel }))
|
|
||||||
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
|
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ permissions }),
|
body: JSON.stringify({ libraryIds: permitted }),
|
||||||
})
|
})
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -272,40 +265,23 @@ function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Li
|
|||||||
{libraries.length === 0 ? (
|
{libraries.length === 0 ? (
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{libraries.map((lib) => {
|
{libraries.map((lib) => (
|
||||||
const current = levels[lib.id] ?? 'none'
|
<label key={lib.id} className="flex items-center gap-2 cursor-pointer">
|
||||||
return (
|
<input
|
||||||
<div key={lib.id} className="flex items-center justify-between gap-3">
|
type="checkbox"
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
checked={permitted.includes(lib.id)}
|
||||||
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
onChange={() => toggle(lib.id)}
|
||||||
{lib.name}
|
className="rounded"
|
||||||
</span>
|
/>
|
||||||
<span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
|
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||||
({lib.type})
|
{lib.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<div
|
({lib.type})
|
||||||
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
|
</span>
|
||||||
style={{ border: '1px solid var(--border)' }}
|
</label>
|
||||||
>
|
))}
|
||||||
{(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => (
|
|
||||||
<button
|
|
||||||
key={lvl}
|
|
||||||
onClick={() => setLevel(lib.id, lvl)}
|
|
||||||
className="px-2.5 py-1 transition-colors capitalize"
|
|
||||||
style={{
|
|
||||||
backgroundColor: current === lvl ? 'var(--accent)' : 'transparent',
|
|
||||||
color: current === lvl ? 'var(--background)' : 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lvl}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -41,32 +41,15 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
|
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
|
||||||
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
|
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
|
||||||
|
|
||||||
// Tools overlay visibility
|
|
||||||
const [showToolsOverlay, setShowToolsOverlay] = useState(false)
|
|
||||||
|
|
||||||
// Rating state
|
|
||||||
const [userRating, setUserRatingState] = useState<number | null>(null)
|
|
||||||
const [ratingHover, setRatingHover] = useState<number | null>(null)
|
|
||||||
const [savingRating, setSavingRating] = useState(false)
|
|
||||||
|
|
||||||
// Text overlay state
|
// Text overlay state
|
||||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
|
||||||
const [savingText, setSavingText] = useState(false)
|
|
||||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
const [extractPending, setExtractPending] = useState(false)
|
|
||||||
const [retranslating, setRetranslating] = useState(false)
|
|
||||||
const [translatePending, setTranslatePending] = useState(false)
|
|
||||||
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
|
||||||
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
|
||||||
const [sourceLanguage, setSourceLanguage] = useState('')
|
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
const cooldownRef = useRef(false)
|
const cooldownRef = useRef(false)
|
||||||
const touchStartY = useRef<number | null>(null)
|
const touchStartY = useRef<number | null>(null)
|
||||||
|
|
||||||
@@ -143,59 +126,24 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
||||||
|
|
||||||
// Fetch OCR settings once on mount
|
// Fetch extracted text for current item
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/ai-settings/ocr')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
|
||||||
setDefaultOcrLanguages(d.ocrLanguages)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Fetch extracted text + rating for current item; clear any in-flight poll on item change
|
|
||||||
useEffect(() => {
|
|
||||||
if (extractPollRef.current) {
|
|
||||||
clearInterval(extractPollRef.current)
|
|
||||||
extractPollRef.current = null
|
|
||||||
}
|
|
||||||
setExtractedText(null)
|
setExtractedText(null)
|
||||||
setEditedExtractedText('')
|
|
||||||
setTranslatedText(null)
|
setTranslatedText(null)
|
||||||
setShowTextOverlay(false)
|
setShowTextOverlay(false)
|
||||||
setShowOriginal(false)
|
setShowOriginal(false)
|
||||||
setExtracting(false)
|
setExtracting(false)
|
||||||
setExtractError(null)
|
setExtractError(null)
|
||||||
setExtractPending(false)
|
|
||||||
setRetranslating(false)
|
|
||||||
setTranslatePending(false)
|
|
||||||
setUserRatingState(null)
|
|
||||||
setRatingHover(null)
|
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
const key = current.itemKey
|
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
||||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`)
|
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||||
setExtractedText(data.extractedText)
|
setExtractedText(data.extractedText)
|
||||||
setEditedExtractedText(data.extractedText ?? '')
|
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: { userRating: number | null }) => {
|
|
||||||
setUserRatingState(data.userRating)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, [current?.itemKey])
|
}, [current?.itemKey])
|
||||||
|
|
||||||
// Clean up poll on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') { onClose(); return }
|
if (e.key === 'Escape') { onClose(); return }
|
||||||
@@ -234,144 +182,36 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
}
|
}
|
||||||
}, [navigate, onClose, extractedText])
|
}, [navigate, onClose, extractedText])
|
||||||
|
|
||||||
// ── Polling helper ──────────────────────────────────────────────────────────
|
const handleExtractText = async () => {
|
||||||
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => {
|
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
const itemKey = current.itemKey
|
|
||||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
|
||||||
const deadline = Date.now() + 5 * 60 * 1000
|
|
||||||
extractPollRef.current = setInterval(async () => {
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
clearInterval(extractPollRef.current!)
|
|
||||||
extractPollRef.current = null
|
|
||||||
setExtractPending(false)
|
|
||||||
setTranslatePending(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
|
||||||
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
|
|
||||||
const textChanged = data.extractedText !== snapshotText
|
|
||||||
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
|
|
||||||
if (textChanged || translationChanged) {
|
|
||||||
clearInterval(extractPollRef.current!)
|
|
||||||
extractPollRef.current = null
|
|
||||||
setExtractedText(data.extractedText)
|
|
||||||
setEditedExtractedText(data.extractedText ?? '')
|
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
|
||||||
setExtractPending(false)
|
|
||||||
setTranslatePending(false)
|
|
||||||
if (data.extractedText) setShowTextOverlay(true)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, 2000)
|
|
||||||
}, [current?.itemKey])
|
|
||||||
|
|
||||||
// ── Rating actions ───────────────────────────────────────────────────────────
|
|
||||||
const handleSetRating = useCallback(async (star: number) => {
|
|
||||||
if (!current?.itemKey) return
|
|
||||||
const next = userRating === star ? null : star
|
|
||||||
setSavingRating(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ratings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ itemKey: current.itemKey, userRating: next }),
|
|
||||||
})
|
|
||||||
if (res.ok) setUserRatingState(next)
|
|
||||||
} finally {
|
|
||||||
setSavingRating(false)
|
|
||||||
}
|
|
||||||
}, [current?.itemKey, userRating])
|
|
||||||
|
|
||||||
// ── Text extraction ──────────────────────────────────────────────────────────
|
|
||||||
const callExtract = useCallback(async (modeOverride: string) => {
|
|
||||||
if (!current?.itemKey) return
|
|
||||||
const itemKey = current.itemKey
|
|
||||||
setExtracting(true)
|
setExtracting(true)
|
||||||
setExtractError(null)
|
setExtractError(null)
|
||||||
setExtractPending(false)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ itemKey: current.itemKey }),
|
||||||
itemKey,
|
|
||||||
ocrMode: modeOverride,
|
|
||||||
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (res.status === 202) {
|
|
||||||
setExtractPending(true)
|
|
||||||
startPolling(extractedText, translatedText)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||||
}
|
}
|
||||||
|
if (res.status === 202) {
|
||||||
|
setExtractError('Queued — check AI Integrations for progress')
|
||||||
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
|
return
|
||||||
|
}
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
const newText: string | null = result.extractedText || null
|
setExtractedText(result.extractedText || null)
|
||||||
const newTranslated: string | null = result.translatedText || null
|
setTranslatedText(result.translatedText || null)
|
||||||
setExtractedText(newText)
|
if (result.extractedText) setShowTextOverlay(true)
|
||||||
setEditedExtractedText(newText ?? '')
|
|
||||||
setTranslatedText(newTranslated)
|
|
||||||
if (newText) setShowTextOverlay(true)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
|
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
} finally {
|
} finally {
|
||||||
setExtracting(false)
|
setExtracting(false)
|
||||||
}
|
}
|
||||||
}, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling])
|
}
|
||||||
|
|
||||||
// ── Save edited extracted text ───────────────────────────────────────────────
|
|
||||||
const handleSaveExtractedText = useCallback(async () => {
|
|
||||||
if (!current?.itemKey) return
|
|
||||||
setSavingText(true)
|
|
||||||
try {
|
|
||||||
await fetch('/api/ai-tagging/fields', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }),
|
|
||||||
})
|
|
||||||
setExtractedText(editedExtractedText)
|
|
||||||
} finally {
|
|
||||||
setSavingText(false)
|
|
||||||
}
|
|
||||||
}, [current?.itemKey, editedExtractedText])
|
|
||||||
|
|
||||||
// ── Translation ──────────────────────────────────────────────────────────────
|
|
||||||
const handleTranslate = useCallback(async () => {
|
|
||||||
if (!current?.itemKey) return
|
|
||||||
setRetranslating(true)
|
|
||||||
setTranslatePending(false)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
itemKey: current.itemKey,
|
|
||||||
...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (res.status === 202) {
|
|
||||||
setTranslatePending(true)
|
|
||||||
startPolling(extractedText, translatedText)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
|
||||||
}
|
|
||||||
const result = await res.json()
|
|
||||||
setTranslatedText(result.translatedText || null)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setRetranslating(false)
|
|
||||||
}
|
|
||||||
}, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
||||||
@@ -458,197 +298,10 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tools overlay — anchored lower-left, above the bottom bar */}
|
|
||||||
{showToolsOverlay && current?.itemKey && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-16 left-4 z-20 rounded-xl p-4 flex flex-col gap-3 overflow-y-auto"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(10,10,10,0.92)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.12)',
|
|
||||||
width: 'min(320px, calc(100vw - 2rem))',
|
|
||||||
maxHeight: 'calc(100vh - 8rem)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* ── Rating ──────────────────────────────────────────── */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
|
||||||
Rating
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => {
|
|
||||||
const filled = (ratingHover ?? userRating ?? 0) >= star
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={star}
|
|
||||||
onClick={() => handleSetRating(star)}
|
|
||||||
onMouseEnter={() => setRatingHover(star)}
|
|
||||||
disabled={savingRating}
|
|
||||||
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
|
||||||
style={{
|
|
||||||
fontSize: '1.4rem',
|
|
||||||
color: filled ? '#f59e0b' : 'rgba(255,255,255,0.2)',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: '0 2px',
|
|
||||||
cursor: savingRating ? 'wait' : 'pointer',
|
|
||||||
transition: 'color 0.1s',
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Text Extraction (images only) ───────────────────── */}
|
|
||||||
{current.mediaType === 'image' && (
|
|
||||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '0.75rem' }}>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
|
||||||
Text Extraction
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => callExtract('llm')}
|
|
||||||
disabled={extracting || extractPending}
|
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center transition-opacity disabled:opacity-40"
|
|
||||||
style={{
|
|
||||||
backgroundColor: extractPending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
|
||||||
color: extractPending ? '#fff' : 'rgba(255,255,255,0.7)',
|
|
||||||
fontSize: '0.95rem',
|
|
||||||
}}
|
|
||||||
aria-label="Extract with AI"
|
|
||||||
title="Extract with AI (skips OCR)"
|
|
||||||
>
|
|
||||||
{extracting || extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<button
|
|
||||||
onClick={() => callExtract('tesseract')}
|
|
||||||
disabled={extracting || extractPending}
|
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-40 flex-shrink-0"
|
|
||||||
style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'rgba(255,255,255,0.7)' }}
|
|
||||||
>
|
|
||||||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ocrLanguageInput}
|
|
||||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
|
||||||
placeholder={defaultOcrLanguages}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.15)',
|
|
||||||
color: 'rgba(255,255,255,0.85)',
|
|
||||||
width: 120,
|
|
||||||
}}
|
|
||||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{extractError && (
|
|
||||||
<p className="text-xs mt-1" style={{ color: '#f87171' }}>{extractError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Extracted text editor */}
|
|
||||||
{extractedText !== null && (
|
|
||||||
<div className="flex flex-col gap-1 mt-2">
|
|
||||||
<p className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.45)' }}>Extracted Text</p>
|
|
||||||
<textarea
|
|
||||||
value={editedExtractedText}
|
|
||||||
onChange={(e) => setEditedExtractedText(e.target.value)}
|
|
||||||
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.15)',
|
|
||||||
color: 'rgba(255,255,255,0.9)',
|
|
||||||
minHeight: '3.5rem',
|
|
||||||
maxHeight: '8rem',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{editedExtractedText !== extractedText && (
|
|
||||||
<button
|
|
||||||
onClick={handleSaveExtractedText}
|
|
||||||
disabled={savingText}
|
|
||||||
className="self-start text-xs px-2 py-0.5 rounded-full transition-opacity disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{savingText ? '⟳ Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Translation display */}
|
|
||||||
{translatedText && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<p className="text-xs font-medium mb-1" style={{ color: 'rgba(255,255,255,0.45)' }}>Translation</p>
|
|
||||||
<pre
|
|
||||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-32 overflow-y-auto"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.15)',
|
|
||||||
color: 'rgba(255,255,255,0.9)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{translatedText}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Original / translation toggle */}
|
|
||||||
{extractedText && translatedText && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowOriginal((v) => !v)}
|
|
||||||
className="self-start text-xs px-2 py-0.5 rounded-full mt-1"
|
|
||||||
style={{ backgroundColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)' }}
|
|
||||||
>
|
|
||||||
{showOriginal ? 'Show Translation in popover' : 'Show Original in popover'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Translate / re-translate */}
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap mt-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sourceLanguage}
|
|
||||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
|
||||||
placeholder="Source lang…"
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.15)',
|
|
||||||
color: 'rgba(255,255,255,0.85)',
|
|
||||||
width: 100,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleTranslate}
|
|
||||||
disabled={retranslating || translatePending}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-40"
|
|
||||||
style={{
|
|
||||||
backgroundColor: translatePending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
|
||||||
color: translatePending ? '#fff' : 'rgba(255,255,255,0.7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Text overlay */}
|
{/* Text overlay */}
|
||||||
{showTextOverlay && displayText && (
|
{showTextOverlay && displayText && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
|
className="absolute bottom-16 left-4 right-4 z-20 rounded-xl p-4"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -669,9 +322,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom bar: [mute + tools] | filename | action buttons */}
|
{/* Bottom bar: mute | filename | action buttons */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="w-9 flex-shrink-0">
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLocalMuted((v) => !v)}
|
onClick={() => setLocalMuted((v) => !v)}
|
||||||
@@ -694,27 +347,6 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{current?.itemKey && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowToolsOverlay((v) => !v)}
|
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{
|
|
||||||
backgroundColor: showToolsOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
aria-label={showToolsOverlay ? 'Close tools' : 'Open tools'}
|
|
||||||
title="Rating & text tools"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
|
||||||
<line x1="12" y1="2" x2="12" y2="5"/>
|
|
||||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
|
||||||
<line x1="2" y1="12" x2="5" y2="12"/>
|
|
||||||
<line x1="19" y1="12" x2="22" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||||||
{current?.name}
|
{current?.name}
|
||||||
@@ -738,21 +370,16 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
</button>
|
</button>
|
||||||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => callExtract('tesseract')}
|
onClick={handleExtractText}
|
||||||
disabled={extracting || extractPending}
|
disabled={extracting}
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: extractPending
|
backgroundColor: extractError ? 'rgba(127,29,29,0.8)' : 'rgba(0,0,0,0.5)',
|
||||||
? 'var(--accent)'
|
|
||||||
: extractError
|
|
||||||
? 'rgba(127,29,29,0.8)'
|
|
||||||
: 'rgba(0,0,0,0.5)',
|
|
||||||
color: extractError ? '#fca5a5' : '#fff',
|
color: extractError ? '#fca5a5' : '#fff',
|
||||||
}}
|
}}
|
||||||
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
aria-label="Extract text"
|
||||||
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
|
||||||
>
|
>
|
||||||
{extracting || extractPending ? (
|
{extracting ? (
|
||||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
||||||
) : (
|
) : (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Tag, TagCategory, RatingOperator } from '@/types'
|
import type { Tag, TagCategory } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
@@ -11,24 +11,9 @@ interface Props {
|
|||||||
selectedTagIds: Set<string>
|
selectedTagIds: Set<string>
|
||||||
onTagToggle: (tagId: string) => void
|
onTagToggle: (tagId: string) => void
|
||||||
refreshKey?: number
|
refreshKey?: number
|
||||||
ratingValue: number | null
|
|
||||||
ratingOperator: RatingOperator
|
|
||||||
onRatingChange: (value: number | null, operator: RatingOperator) => void
|
|
||||||
showRatingFilter?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilterPanel({
|
export default function FilterPanel({ assignments, search, onSearchChange, selectedTagIds, onTagToggle, refreshKey }: Props) {
|
||||||
assignments,
|
|
||||||
search,
|
|
||||||
onSearchChange,
|
|
||||||
selectedTagIds,
|
|
||||||
onTagToggle,
|
|
||||||
refreshKey,
|
|
||||||
ratingValue,
|
|
||||||
ratingOperator,
|
|
||||||
onRatingChange,
|
|
||||||
showRatingFilter = true,
|
|
||||||
}: Props) {
|
|
||||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
const [categories, setCategories] = useState<TagCategory[]>([])
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -68,59 +53,6 @@ export default function FilterPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rating filter */}
|
|
||||||
{showRatingFilter && (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Rating</p>
|
|
||||||
{/* Operator toggle */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(['gte', 'eq', 'lte'] as RatingOperator[]).map((op) => {
|
|
||||||
const label = op === 'gte' ? '≥' : op === 'eq' ? '=' : '≤'
|
|
||||||
const active = ratingValue !== null && ratingOperator === op
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={op}
|
|
||||||
onClick={() => onRatingChange(active ? null : (ratingValue ?? 3), op)}
|
|
||||||
className="flex-1 py-0.5 rounded text-xs font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: active ? 'var(--accent)' : 'var(--border)',
|
|
||||||
color: active ? '#fff' : 'var(--text-secondary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* Star picker */}
|
|
||||||
<div className="flex gap-0.5">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => {
|
|
||||||
const lit =
|
|
||||||
ratingValue !== null &&
|
|
||||||
((ratingOperator === 'gte' && star <= ratingValue) ||
|
|
||||||
(ratingOperator === 'eq' && star === ratingValue) ||
|
|
||||||
(ratingOperator === 'lte' && star >= ratingValue))
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={star}
|
|
||||||
onClick={() => onRatingChange(ratingValue === star ? null : star, ratingOperator)}
|
|
||||||
className="flex-1 text-base py-0.5 rounded transition-colors"
|
|
||||||
style={{
|
|
||||||
color: lit ? '#f59e0b' : 'var(--border)',
|
|
||||||
background: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
aria-label={`${star} star${star !== 1 ? 's' : ''}`}
|
|
||||||
>
|
|
||||||
★
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tag filters */}
|
{/* Tag filters */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -130,7 +62,7 @@ export default function FilterPanel({
|
|||||||
className="h-3 w-16 rounded animate-pulse"
|
className="h-3 w-16 rounded animate-pulse"
|
||||||
style={{ backgroundColor: 'var(--border)' }}
|
style={{ backgroundColor: 'var(--border)' }}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{[50, 65, 42].map((w) => (
|
{[50, 65, 42].map((w) => (
|
||||||
<div
|
<div
|
||||||
key={w}
|
key={w}
|
||||||
@@ -152,7 +84,7 @@ export default function FilterPanel({
|
|||||||
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{catTags.map((tag) => {
|
{catTags.map((tag) => {
|
||||||
const active = selectedTagIds.has(tag.id)
|
const active = selectedTagIds.has(tag.id)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import type { ComicIssue } from '@/types'
|
|
||||||
import ImageLightbox from '@/components/mixed/ImageLightbox'
|
|
||||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
|
||||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
|
||||||
|
|
||||||
function fileApiUrl(libraryId: string, relativePath: string): string {
|
|
||||||
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
libraryId: string
|
|
||||||
issue: ComicIssue
|
|
||||||
onClose: () => void
|
|
||||||
onPrev?: () => void
|
|
||||||
onNext?: () => void
|
|
||||||
onTagsChanged?: () => void
|
|
||||||
onDeleted?: () => void
|
|
||||||
readOnly?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string {
|
|
||||||
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComicIssueView({ libraryId, issue, onClose, onPrev, onNext, onTagsChanged, onDeleted, readOnly }: Props) {
|
|
||||||
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
|
|
||||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
|
||||||
const [confirming, setConfirming] = useState(false)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (lightboxPage !== null) return
|
|
||||||
if (e.key === 'ArrowLeft') { onPrev?.(); return }
|
|
||||||
if (e.key === 'ArrowRight') { onNext?.(); return }
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
|
||||||
if (confirming) { setConfirming(false); return }
|
|
||||||
if (showTagPanel) { setShowTagPanel(false); return }
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKey)
|
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
|
||||||
}, [onClose, onPrev, onNext, lightboxPage, showTagPanel, menuOpen, confirming])
|
|
||||||
|
|
||||||
// Close menu on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!menuOpen) return
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handler)
|
|
||||||
return () => document.removeEventListener('mousedown', handler)
|
|
||||||
}, [menuOpen])
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
await fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}`, { method: 'DELETE' })
|
|
||||||
onDeleted?.()
|
|
||||||
} catch {
|
|
||||||
setDeleting(false)
|
|
||||||
setConfirming(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageCount = issue.pageCount
|
|
||||||
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
|
|
||||||
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 overflow-hidden"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{/* Floating prev/next arrows */}
|
|
||||||
{onPrev && !showTagPanel && (
|
|
||||||
<button
|
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
|
||||||
aria-label="Previous issue"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onNext && !showTagPanel && (
|
|
||||||
<button
|
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
|
||||||
aria-label="Next issue"
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
|
||||||
<div
|
|
||||||
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
|
|
||||||
style={{ borderBottom: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{issue.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{pageCount} {pageCount === 1 ? 'page' : 'pages'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
|
||||||
{issue.item_key && !readOnly && !showTagPanel && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTagPanel(true) }}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
title="Tags"
|
|
||||||
aria-label="Show tags"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Kebab menu */}
|
|
||||||
<div className="relative" ref={menuRef}>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v) }}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-base font-bold transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
aria-label="More options"
|
|
||||||
title="More options"
|
|
||||||
>
|
|
||||||
⋮
|
|
||||||
</button>
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-10 min-w-[120px]"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={downloadUrl}
|
|
||||||
download
|
|
||||||
className="flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
{!readOnly && (
|
|
||||||
<button
|
|
||||||
className="w-full text-left flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
|
|
||||||
style={{ color: '#fca5a5' }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
|
||||||
{confirming && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-3 mx-5 mt-3 px-3 py-2.5 rounded-lg text-sm flex-shrink-0"
|
|
||||||
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
|
||||||
>
|
|
||||||
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
|
||||||
Permanently delete this issue and its file?
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirming(false)}
|
|
||||||
className="px-2 py-1 rounded text-xs transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="px-2 py-1 rounded text-xs font-medium transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
|
||||||
>
|
|
||||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cover + tags */}
|
|
||||||
<div
|
|
||||||
className="flex gap-5 px-5 py-4 flex-shrink-0"
|
|
||||||
style={{ borderBottom: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 rounded-lg overflow-hidden"
|
|
||||||
style={{ width: 140, aspectRatio: '2/3', background: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
{issue.coverUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={issue.coverUrl}
|
|
||||||
alt={issue.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : pageCount > 0 ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={pageUrl(libraryId, issueKey, 0)}
|
|
||||||
alt={issue.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="w-full h-full flex items-center justify-center text-3xl"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
📖
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 pt-1">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
{issue.item_key ? (
|
|
||||||
<AssignedTagBadges itemKey={issueKey} refreshKey={tagRefreshKey} />
|
|
||||||
) : (
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No tags</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page grid */}
|
|
||||||
<div className="overflow-y-auto flex-1 p-4" ref={gridRef}>
|
|
||||||
{pageCount === 0 ? (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center py-16 text-sm"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
No pages found in this issue.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-2 grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
|
||||||
{Array.from({ length: pageCount }, (_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className="relative rounded overflow-hidden focus:outline-none focus:ring-2 focus:ring-offset-1 group"
|
|
||||||
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
|
||||||
onClick={() => setLightboxPage(i)}
|
|
||||||
aria-label={`Page ${i + 1}`}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={pageUrl(libraryId, issueKey, i)}
|
|
||||||
alt={`Page ${i + 1}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 inset-x-0 py-0.5 text-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTagPanel && issue.item_key && (
|
|
||||||
<MediaTagPanel
|
|
||||||
itemKey={issueKey}
|
|
||||||
onHide={() => setShowTagPanel(false)}
|
|
||||||
onClose={onClose}
|
|
||||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lightboxPage !== null && (
|
|
||||||
<ImageLightbox
|
|
||||||
url={pageUrl(libraryId, issueKey, lightboxPage)}
|
|
||||||
name={`Page ${lightboxPage + 1} of ${pageCount}`}
|
|
||||||
onClose={() => setLightboxPage(null)}
|
|
||||||
onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined}
|
|
||||||
onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined}
|
|
||||||
itemKey={issueKey}
|
|
||||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import type { ComicIssue, ComicSeries } from '@/types'
|
|
||||||
import ComicIssueView from './ComicIssueView'
|
|
||||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
libraryId: string
|
|
||||||
series: ComicSeries
|
|
||||||
onClose: () => void
|
|
||||||
onTagsChanged?: () => void
|
|
||||||
readOnly?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComicSeriesView({ libraryId, series, onClose, onTagsChanged, readOnly }: Props) {
|
|
||||||
const [issues, setIssues] = useState<ComicIssue[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
|
||||||
const [tagItemKey, setTagItemKey] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const fetchIssues = useCallback(() => {
|
|
||||||
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(series.id)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: ComicIssue[]) => {
|
|
||||||
setIssues(data)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false))
|
|
||||||
}, [libraryId, series.id])
|
|
||||||
|
|
||||||
useEffect(() => { fetchIssues() }, [fetchIssues])
|
|
||||||
|
|
||||||
// Escape closes tag panel first, then series view
|
|
||||||
useEffect(() => {
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape' && !selectedIssue && !tagItemKey) onClose()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKey)
|
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
|
||||||
}, [onClose, selectedIssue, tagItemKey])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40 overflow-hidden"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div className={`flex h-full w-full ${tagItemKey ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
|
||||||
<div className={tagItemKey ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-3xl'}>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
|
|
||||||
style={{ borderBottom: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{series.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
|
||||||
{series.item_key && !readOnly && !tagItemKey && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setTagItemKey(series.item_key!) }}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
title="Tag series"
|
|
||||||
aria-label="Tag series"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Issue grid */}
|
|
||||||
<div className="overflow-y-auto flex-1 p-4">
|
|
||||||
{loading ? (
|
|
||||||
<LoadingGrid />
|
|
||||||
) : issues.length === 0 ? (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center py-16 text-sm"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
No issues found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
||||||
{issues.map((issue) => (
|
|
||||||
<IssueCard
|
|
||||||
key={issue.id}
|
|
||||||
issue={issue}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onClick={() => setSelectedIssue(issue)}
|
|
||||||
onTagClick={issue.item_key && !readOnly
|
|
||||||
? () => setTagItemKey(issue.item_key!)
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tagItemKey && (
|
|
||||||
<MediaTagPanel
|
|
||||||
itemKey={tagItemKey}
|
|
||||||
onHide={() => setTagItemKey(null)}
|
|
||||||
onClose={onClose}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedIssue && (
|
|
||||||
<ComicIssueView
|
|
||||||
libraryId={libraryId}
|
|
||||||
issue={selectedIssue}
|
|
||||||
onClose={() => setSelectedIssue(null)}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function IssueCard({
|
|
||||||
issue,
|
|
||||||
onClick,
|
|
||||||
onTagClick,
|
|
||||||
readOnly,
|
|
||||||
}: {
|
|
||||||
issue: ComicIssue
|
|
||||||
onClick: () => void
|
|
||||||
onTagClick?: () => void
|
|
||||||
readOnly?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative rounded-xl overflow-hidden group"
|
|
||||||
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="text-left w-full focus:outline-none focus:ring-2"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative w-full overflow-hidden"
|
|
||||||
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
{issue.coverUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={issue.coverUrl}
|
|
||||||
alt={issue.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-3xl">📖</div>
|
|
||||||
)}
|
|
||||||
{issue.issueNumber !== null && (
|
|
||||||
<div
|
|
||||||
className="absolute top-1 left-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
#{issue.issueNumber}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-2 pt-1.5 pb-1">
|
|
||||||
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{issue.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{onTagClick && !readOnly && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
|
||||||
className="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
|
||||||
title="Tag issue"
|
|
||||||
aria-label="Tag issue"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingGrid() {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
||||||
{Array.from({ length: 6 }, (_, i) => (
|
|
||||||
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '80%' }} />
|
|
||||||
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,561 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
|
||||||
import type { ComicIssue, ComicSeries, RatingOperator } from '@/types'
|
|
||||||
import { useDebounce } from '@/hooks/useDebounce'
|
|
||||||
import ComicIssueView from './ComicIssueView'
|
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
libraryId: string
|
|
||||||
readOnly?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 200
|
|
||||||
|
|
||||||
export default function ComicsView({ libraryId, readOnly }: Props) {
|
|
||||||
const [items, setItems] = useState<(ComicIssue | ComicSeries)[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
|
|
||||||
const [seriesIssues, setSeriesIssues] = useState<ComicIssue[]>([])
|
|
||||||
const [seriesIssuesLoading, setSeriesIssuesLoading] = useState(false)
|
|
||||||
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
|
||||||
const [selectedIssueIndex, setSelectedIssueIndex] = useState<number | null>(null)
|
|
||||||
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
|
||||||
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
|
||||||
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
|
||||||
const debouncedSearch = useDebounce(search, 200)
|
|
||||||
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
|
|
||||||
Record<string, { tagIds: string[]; issueTitles: string[] }>
|
|
||||||
>({})
|
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
|
||||||
const [showFilters, setShowFilters] = useState(
|
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
|
||||||
)
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
|
||||||
setSelectedTagIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchItems = useCallback((pageNum: number, searchVal: string, replace: boolean) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
libraryId,
|
|
||||||
page: String(pageNum),
|
|
||||||
pageSize: String(PAGE_SIZE),
|
|
||||||
})
|
|
||||||
if (searchVal) params.set('search', searchVal)
|
|
||||||
if (pageNum === 1) setLoading(true)
|
|
||||||
else setLoadingMore(true)
|
|
||||||
fetch(`/api/comics?${params}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: { items: (ComicIssue | ComicSeries)[]; total: number }) => {
|
|
||||||
setItems((prev) => (replace ? data.items : [...prev, ...data.items]))
|
|
||||||
setTotal(data.total)
|
|
||||||
if (pageNum === 1) setLoading(false)
|
|
||||||
else setLoadingMore(false)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setError('Failed to load comics')
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
}, [libraryId])
|
|
||||||
|
|
||||||
useEffect(() => { fetchItems(1, '', true) }, [fetchItems])
|
|
||||||
|
|
||||||
// Fetch issues when a series is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedSeries) { setSeriesIssues([]); return }
|
|
||||||
setSeriesIssuesLoading(true)
|
|
||||||
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: ComicIssue[]) => { setSeriesIssues(data); setSeriesIssuesLoading(false) })
|
|
||||||
.catch(() => setSeriesIssuesLoading(false))
|
|
||||||
}, [selectedSeries, libraryId])
|
|
||||||
|
|
||||||
// IntersectionObserver: load next page when sentinel scrolls into view
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = sentinelRef.current
|
|
||||||
if (!sentinel || items.length >= total || total === 0) return
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting && !loadingMore) {
|
|
||||||
const next = page + 1
|
|
||||||
setPage(next)
|
|
||||||
fetchItems(next, search, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: '400px' }
|
|
||||||
)
|
|
||||||
observer.observe(sentinel)
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [items.length, total, loadingMore, page, search, fetchItems])
|
|
||||||
|
|
||||||
const handleSearchChange = useCallback((val: string) => {
|
|
||||||
setSearch(val)
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
setPage(1)
|
|
||||||
fetchItems(1, val, true)
|
|
||||||
}, 300)
|
|
||||||
}, [fetchItems])
|
|
||||||
|
|
||||||
const fetchAssignments = useCallback(() => {
|
|
||||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then(setAssignments)
|
|
||||||
.catch(() => {})
|
|
||||||
}, [libraryId])
|
|
||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
|
||||||
|
|
||||||
const fetchSeriesIssueMeta = useCallback(() => {
|
|
||||||
fetch(`/api/comics/series-issue-tags?libraryId=${encodeURIComponent(libraryId)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then(setSeriesIssueMeta)
|
|
||||||
.catch(() => {})
|
|
||||||
}, [libraryId])
|
|
||||||
|
|
||||||
useEffect(() => { fetchSeriesIssueMeta() }, [fetchSeriesIssueMeta])
|
|
||||||
|
|
||||||
const onTagsChanged = useCallback(() => {
|
|
||||||
setFilterRefreshKey((k) => k + 1)
|
|
||||||
fetchAssignments()
|
|
||||||
fetchSeriesIssueMeta()
|
|
||||||
}, [fetchAssignments, fetchSeriesIssueMeta])
|
|
||||||
|
|
||||||
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
|
||||||
if (value === ratingValue && operator === ratingOperator) {
|
|
||||||
setRatingValue(null)
|
|
||||||
} else {
|
|
||||||
setRatingValue(value)
|
|
||||||
setRatingOperator(operator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = useMemo(() => items.filter((item) => {
|
|
||||||
const isSeries = 'issueCount' in item
|
|
||||||
const series = isSeries ? (item as ComicSeries) : null
|
|
||||||
const issue = isSeries ? null : (item as ComicIssue)
|
|
||||||
|
|
||||||
if (series) {
|
|
||||||
const meta = seriesIssueMeta[series.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
|
|
||||||
|
|
||||||
if (debouncedSearch) {
|
|
||||||
const q = debouncedSearch.toLowerCase()
|
|
||||||
const titleMatch = series.title.toLowerCase().includes(q)
|
|
||||||
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
|
|
||||||
const aiMatch = series.aiDescription?.toLowerCase().includes(q) ?? false
|
|
||||||
const textMatch = series.extractedText?.toLowerCase().includes(q) ?? false
|
|
||||||
const translatedMatch = series.extractedTextTranslated?.toLowerCase().includes(q) ?? false
|
|
||||||
if (!titleMatch && !issueMatch && !aiMatch && !textMatch && !translatedMatch) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTagIds.size > 0) {
|
|
||||||
const seriesTags = assignments[series.item_key ?? ''] ?? []
|
|
||||||
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
|
|
||||||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ratingValue !== null) {
|
|
||||||
const r = series.userRating
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte' && r < ratingValue) return false
|
|
||||||
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
|
||||||
if (ratingOperator === 'lte' && r > ratingValue) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standalone issue
|
|
||||||
if (debouncedSearch) {
|
|
||||||
const q = debouncedSearch.toLowerCase()
|
|
||||||
if (![issue!.title, issue!.aiDescription, issue!.extractedText, issue!.extractedTextTranslated]
|
|
||||||
.some((f) => f?.toLowerCase().includes(q))) return false
|
|
||||||
}
|
|
||||||
if (selectedTagIds.size > 0) {
|
|
||||||
const tags = assignments[issue!.item_key ?? ''] ?? []
|
|
||||||
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
|
|
||||||
}
|
|
||||||
if (ratingValue !== null) {
|
|
||||||
const r = issue!.userRating
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte' && r < ratingValue) return false
|
|
||||||
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
|
||||||
if (ratingOperator === 'lte' && r > ratingValue) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}), [items, debouncedSearch, selectedTagIds, assignments, seriesIssueMeta, ratingValue, ratingOperator])
|
|
||||||
|
|
||||||
// Flat list of issues at the current navigation level for prev/next
|
|
||||||
const filteredIssues: ComicIssue[] = selectedSeries
|
|
||||||
? seriesIssues
|
|
||||||
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
|
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFilters((v) => !v)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
|
||||||
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
|
||||||
>
|
|
||||||
Filters{filtersActive ? ' ●' : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
|
||||||
{showFilters && (
|
|
||||||
<div className="w-full md:w-52 md:flex-shrink-0">
|
|
||||||
<FilterPanel
|
|
||||||
libraryId={libraryId}
|
|
||||||
assignments={assignments}
|
|
||||||
search={search}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
selectedTagIds={selectedTagIds}
|
|
||||||
onTagToggle={toggleTag}
|
|
||||||
refreshKey={filterRefreshKey}
|
|
||||||
ratingValue={ratingValue}
|
|
||||||
ratingOperator={ratingOperator}
|
|
||||||
onRatingChange={handleRatingChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Breadcrumb when inside a series */}
|
|
||||||
{selectedSeries && (
|
|
||||||
<div className="flex items-center gap-2 mb-4 text-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedSeries(null); setSeriesIssues([]); setSearch('') }}
|
|
||||||
className="transition-colors"
|
|
||||||
style={{ color: 'var(--accent)' }}
|
|
||||||
>
|
|
||||||
All Comics
|
|
||||||
</button>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
|
||||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{selectedSeries.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<LoadingGrid />
|
|
||||||
) : error ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border p-8 text-center"
|
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border p-12 text-center"
|
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
<p className="text-lg mb-1">No comics found</p>
|
|
||||||
<p className="text-sm">Add .cbz files or folders of .cbz files to this library and scan.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!selectedSeries && total > PAGE_SIZE && (
|
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{seriesIssuesLoading ? (
|
|
||||||
<LoadingGrid />
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
||||||
{selectedSeries
|
|
||||||
? seriesIssues.map((issue) => (
|
|
||||||
<IssueCard
|
|
||||||
key={issue.id}
|
|
||||||
issue={issue}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onClick={() => { setSelectedIssue(issue); setSelectedIssueIndex(seriesIssues.indexOf(issue)) }}
|
|
||||||
onTagClick={issue.item_key && !readOnly
|
|
||||||
? () => setTagPanel({ itemKey: issue.item_key!, title: issue.title })
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: filtered.map((item) =>
|
|
||||||
'issueCount' in item ? (
|
|
||||||
<SeriesCard
|
|
||||||
key={item.id}
|
|
||||||
series={item as ComicSeries}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onClick={() => { setSelectedSeries(item as ComicSeries); setSearch('') }}
|
|
||||||
onTagClick={(item as ComicSeries).item_key && !readOnly
|
|
||||||
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IssueCard
|
|
||||||
key={item.id}
|
|
||||||
issue={item as ComicIssue}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onClick={() => {
|
|
||||||
const issue = item as ComicIssue
|
|
||||||
setSelectedIssue(issue)
|
|
||||||
setSelectedIssueIndex(filteredIssues.indexOf(issue))
|
|
||||||
}}
|
|
||||||
onTagClick={(item as ComicIssue).item_key && !readOnly
|
|
||||||
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!selectedSeries && (
|
|
||||||
<>
|
|
||||||
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
|
|
||||||
{loadingMore && <LoadingMore />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag panel modal */}
|
|
||||||
{tagPanel && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-5 py-4"
|
|
||||||
style={{ borderBottom: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{tagPanel.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setTagPanel(null)}
|
|
||||||
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4">
|
|
||||||
<TagSelector
|
|
||||||
itemKey={tagPanel.itemKey}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedIssue && (
|
|
||||||
<ComicIssueView
|
|
||||||
libraryId={libraryId}
|
|
||||||
issue={selectedIssue}
|
|
||||||
onClose={() => { setSelectedIssue(null); setSelectedIssueIndex(null) }}
|
|
||||||
onPrev={selectedIssueIndex !== null && selectedIssueIndex > 0
|
|
||||||
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex - 1]); setSelectedIssueIndex(selectedIssueIndex - 1) }
|
|
||||||
: undefined}
|
|
||||||
onNext={selectedIssueIndex !== null && selectedIssueIndex < filteredIssues.length - 1
|
|
||||||
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex + 1]); setSelectedIssueIndex(selectedIssueIndex + 1) }
|
|
||||||
: undefined}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
onDeleted={() => {
|
|
||||||
setSelectedIssue(null)
|
|
||||||
setSelectedIssueIndex(null)
|
|
||||||
fetchItems(1, search, true)
|
|
||||||
fetchAssignments()
|
|
||||||
if (selectedSeries) {
|
|
||||||
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: ComicIssue[]) => setSeriesIssues(data))
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SeriesCard({
|
|
||||||
series,
|
|
||||||
onClick,
|
|
||||||
onTagClick,
|
|
||||||
readOnly,
|
|
||||||
}: {
|
|
||||||
series: ComicSeries
|
|
||||||
onClick: () => void
|
|
||||||
onTagClick?: () => void
|
|
||||||
readOnly?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative rounded-xl overflow-hidden group"
|
|
||||||
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
|
||||||
>
|
|
||||||
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
|
|
||||||
<div
|
|
||||||
className="relative w-full overflow-hidden"
|
|
||||||
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
{series.coverUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={series.coverUrl}
|
|
||||||
alt={series.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-4xl">📚</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="absolute top-1 right-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{series.issueCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 pt-1.5 pb-1">
|
|
||||||
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{series.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{onTagClick && !readOnly && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
|
||||||
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
|
||||||
title="Tag series"
|
|
||||||
aria-label="Tag series"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function IssueCard({
|
|
||||||
issue,
|
|
||||||
onClick,
|
|
||||||
onTagClick,
|
|
||||||
readOnly,
|
|
||||||
}: {
|
|
||||||
issue: ComicIssue
|
|
||||||
onClick: () => void
|
|
||||||
onTagClick?: () => void
|
|
||||||
readOnly?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative rounded-xl overflow-hidden group"
|
|
||||||
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
|
||||||
>
|
|
||||||
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
|
|
||||||
<div
|
|
||||||
className="relative w-full overflow-hidden"
|
|
||||||
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
{issue.coverUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={issue.coverUrl}
|
|
||||||
alt={issue.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-4xl">📖</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-2 pt-1.5 pb-1">
|
|
||||||
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{issue.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{onTagClick && !readOnly && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
|
||||||
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
|
||||||
title="Tag issue"
|
|
||||||
aria-label="Tag issue"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingMore() {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center py-6">
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full border-2 animate-spin"
|
|
||||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingGrid() {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
||||||
{Array.from({ length: 12 }, (_, i) => (
|
|
||||||
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '75%' }} />
|
|
||||||
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { Game, GameFile, GamePlatform } from '@/types'
|
import type { Game, GameFile, GamePlatform } from '@/types'
|
||||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
|
||||||
|
|
||||||
// Import SVG icons
|
// Import SVG icons
|
||||||
import WindowsIcon from '@/app/icons/windows.svg'
|
import WindowsIcon from '@/app/icons/windows.svg'
|
||||||
@@ -30,15 +29,12 @@ interface Props {
|
|||||||
game: Game
|
game: Game
|
||||||
libraryId: string
|
libraryId: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onPrev?: () => void
|
|
||||||
onNext?: () => void
|
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onCoverUploaded?: () => void
|
onCoverUploaded?: () => void
|
||||||
onDeleted?: (gameId: string) => void
|
onDeleted?: (gameId: string) => void
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) {
|
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const screenshotInputRef = useRef<HTMLInputElement>(null)
|
const screenshotInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -50,9 +46,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
const [renameName, setRenameName] = useState('')
|
const [renameName, setRenameName] = useState('')
|
||||||
const [renameError, setRenameError] = useState<string | null>(null)
|
const [renameError, setRenameError] = useState<string | null>(null)
|
||||||
const [renameSaving, setRenameSaving] = useState(false)
|
const [renameSaving, setRenameSaving] = useState(false)
|
||||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Screenshots state
|
// Screenshots state
|
||||||
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
|
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
|
||||||
@@ -61,8 +54,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
|
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
|
||||||
const [uploadingCount, setUploadingCount] = useState(0)
|
const [uploadingCount, setUploadingCount] = useState(0)
|
||||||
|
|
||||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
|
||||||
|
|
||||||
const fetchScreenshots = useCallback(() => {
|
const fetchScreenshots = useCallback(() => {
|
||||||
setScreenshotsLoading(true)
|
setScreenshotsLoading(true)
|
||||||
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
|
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
|
||||||
@@ -74,14 +65,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
|
|
||||||
useEffect(() => { fetchScreenshots() }, [fetchScreenshots])
|
useEffect(() => { fetchScreenshots() }, [fetchScreenshots])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!game.item_key) return
|
|
||||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(game.item_key)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d: { aiDescription: string | null }) => setAiDescription(d.aiDescription ?? null))
|
|
||||||
.catch(() => {})
|
|
||||||
}, [game.item_key])
|
|
||||||
|
|
||||||
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files ?? [])
|
const files = Array.from(e.target.files ?? [])
|
||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
@@ -123,14 +106,11 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
|
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowLeft') { onPrev?.(); return }
|
|
||||||
if (e.key === 'ArrowRight') { onNext?.(); return }
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
if (confirming) { setConfirming(false); return }
|
if (confirming) { setConfirming(false); return }
|
||||||
if (renaming) { setRenaming(false); return }
|
if (renaming) { setRenaming(false); return }
|
||||||
if (editingImages) { setEditingImages(false); return }
|
if (editingImages) { setEditingImages(false); return }
|
||||||
if (showTagPanel) { setShowTagPanel(false); return }
|
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +120,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
|
}, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,372 +153,306 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 overflow-hidden"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
<div
|
||||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
{/* ── Left pane — relative container for floating controls ── */}
|
>
|
||||||
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
{editingImages ? (
|
||||||
{/* Scrollable card area */}
|
<ImageEditor
|
||||||
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
game={game}
|
||||||
<div
|
libraryId={libraryId}
|
||||||
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
onBack={() => setEditingImages(false)}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onUploaded={onCoverUploaded}
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
) : (
|
||||||
{editingImages ? (
|
<>
|
||||||
<ImageEditor
|
{/* Close button */}
|
||||||
game={game}
|
|
||||||
libraryId={libraryId}
|
|
||||||
onBack={() => setEditingImages(false)}
|
|
||||||
onUploaded={onCoverUploaded}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
|
|
||||||
{/* Hero image */}
|
|
||||||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
|
||||||
{heroImage ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
|
|
||||||
) : (
|
|
||||||
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="p-5">
|
|
||||||
{/* Title row with kebab menu */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{game.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Kebab menu */}
|
|
||||||
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
aria-label="More options"
|
|
||||||
>
|
|
||||||
⋮
|
|
||||||
</button>
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
Edit images
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false)
|
|
||||||
setRenameName(decodeURIComponent(game.id))
|
|
||||||
setRenameError(null)
|
|
||||||
setRenaming(true)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
Rename folder
|
|
||||||
</button>
|
|
||||||
{onDeleted && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
|
||||||
style={{ color: '#fca5a5' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
Delete game
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI description (read-only) */}
|
|
||||||
{aiDescription && (
|
|
||||||
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{aiDescription}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rename inline input */}
|
|
||||||
{renaming && (
|
|
||||||
<div className="flex flex-col gap-2 mb-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={renameName}
|
|
||||||
onChange={(e) => setRenameName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const trimmed = renameName.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
setRenameSaving(true)
|
|
||||||
setRenameError(null)
|
|
||||||
fetch('/api/rename', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
|
|
||||||
})
|
|
||||||
.then(async (res) => {
|
|
||||||
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
|
||||||
if (!res.ok) throw new Error()
|
|
||||||
setRenaming(false)
|
|
||||||
onCoverUploaded?.() // triggers refetch
|
|
||||||
})
|
|
||||||
.catch(() => setRenameError('Rename failed'))
|
|
||||||
.finally(() => setRenameSaving(false))
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') setRenaming(false)
|
|
||||||
}}
|
|
||||||
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => setRenaming(false)}
|
|
||||||
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const trimmed = renameName.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
setRenameSaving(true)
|
|
||||||
setRenameError(null)
|
|
||||||
fetch('/api/rename', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
|
|
||||||
})
|
|
||||||
.then(async (res) => {
|
|
||||||
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
|
||||||
if (!res.ok) throw new Error()
|
|
||||||
setRenaming(false)
|
|
||||||
onCoverUploaded?.()
|
|
||||||
})
|
|
||||||
.catch(() => setRenameError('Rename failed'))
|
|
||||||
.finally(() => setRenameSaving(false))
|
|
||||||
}}
|
|
||||||
disabled={renameSaving}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{renameSaving ? '…' : 'Rename'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation banner */}
|
|
||||||
{confirming && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
|
||||||
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
|
||||||
>
|
|
||||||
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
|
||||||
Permanently delete this game and all its files?
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirming(false)}
|
|
||||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDeleting(true)
|
|
||||||
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
|
|
||||||
.then(() => onDeleted!(game.id))
|
|
||||||
.catch(() => setDeleting(false))
|
|
||||||
}}
|
|
||||||
disabled={deleting}
|
|
||||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
|
|
||||||
>
|
|
||||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assigned tags (read-only) above download */}
|
|
||||||
{game.item_key && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<AssignedTagBadges itemKey={game.item_key} refreshKey={tagRefreshKey} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
|
|
||||||
|
|
||||||
{/* Screenshots */}
|
|
||||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Screenshots
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
|
|
||||||
{screenshotsLoading && screenshots.length === 0 ? (
|
|
||||||
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{screenshots.map((shot, idx) => (
|
|
||||||
<div
|
|
||||||
key={shot.filename}
|
|
||||||
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
|
|
||||||
style={{ backgroundColor: 'var(--border)' }}
|
|
||||||
onClick={() => setLightboxIndex(idx)}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
|
|
||||||
{deletingScreenshot !== shot.filename && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
|
|
||||||
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
|
||||||
aria-label="Delete screenshot"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deletingScreenshot === shot.filename && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
||||||
<span className="text-xs text-white">Deleting…</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Array.from({ length: uploadingCount }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={`uploading-${i}`}
|
|
||||||
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
|
|
||||||
style={{ backgroundColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading…</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => screenshotInputRef.current?.click()}
|
|
||||||
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
|
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
aria-label="Add screenshot"
|
|
||||||
>
|
|
||||||
<span className="text-xl">+</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={screenshotInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleScreenshotUpload}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating controls — tag + close */}
|
|
||||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{game.item_key && !showTagPanel && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTagPanel(true)}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
||||||
aria-label="Show tags"
|
|
||||||
title="Tags"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={smallBtn}
|
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prev / Next */}
|
{/* Hero image */}
|
||||||
{onPrev && (
|
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
<button
|
{heroImage ? (
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
) : (
|
||||||
aria-label="Previous"
|
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
|
||||||
>
|
)}
|
||||||
‹
|
</div>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onNext && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
{/* Info */}
|
||||||
{showTagPanel && (
|
<div className="p-5">
|
||||||
<MediaTagPanel
|
{/* Title row with kebab menu */}
|
||||||
itemKey={game.item_key!}
|
<div className="flex items-center gap-2 mb-4">
|
||||||
onHide={() => setShowTagPanel(false)}
|
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
||||||
onClose={onClose}
|
{game.title}
|
||||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
</h2>
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
{/* Kebab menu */}
|
||||||
|
<div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Edit images
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setRenameName(decodeURIComponent(game.id))
|
||||||
|
setRenameError(null)
|
||||||
|
setRenaming(true)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Rename folder
|
||||||
|
</button>
|
||||||
|
{onDeleted && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Delete game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename inline input */}
|
||||||
|
{renaming && (
|
||||||
|
<div className="flex flex-col gap-2 mb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameName}
|
||||||
|
onChange={(e) => setRenameName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const trimmed = renameName.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setRenameSaving(true)
|
||||||
|
setRenameError(null)
|
||||||
|
fetch('/api/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
setRenaming(false)
|
||||||
|
onCoverUploaded?.() // triggers refetch
|
||||||
|
})
|
||||||
|
.catch(() => setRenameError('Rename failed'))
|
||||||
|
.finally(() => setRenameSaving(false))
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setRenaming(false)
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setRenaming(false)}
|
||||||
|
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = renameName.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setRenameSaving(true)
|
||||||
|
setRenameError(null)
|
||||||
|
fetch('/api/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
setRenaming(false)
|
||||||
|
onCoverUploaded?.()
|
||||||
|
})
|
||||||
|
.catch(() => setRenameError('Rename failed'))
|
||||||
|
.finally(() => setRenameSaving(false))
|
||||||
|
}}
|
||||||
|
disabled={renameSaving}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{renameSaving ? '…' : 'Rename'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation banner */}
|
||||||
|
{confirming && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||||||
|
>
|
||||||
|
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||||||
|
Permanently delete this game and all its files?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDeleting(true)
|
||||||
|
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
|
||||||
|
.then(() => onDeleted!(game.id))
|
||||||
|
.catch(() => setDeleting(false))
|
||||||
|
}}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
|
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Screenshots
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
|
||||||
|
{screenshotsLoading && screenshots.length === 0 ? (
|
||||||
|
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{screenshots.map((shot, idx) => (
|
||||||
|
<div
|
||||||
|
key={shot.filename}
|
||||||
|
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--border)' }}
|
||||||
|
onClick={() => setLightboxIndex(idx)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
|
||||||
|
{deletingScreenshot !== shot.filename && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
|
||||||
|
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
||||||
|
aria-label="Delete screenshot"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{deletingScreenshot === shot.filename && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<span className="text-xs text-white">Deleting…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: uploadingCount }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`uploading-${i}`}
|
||||||
|
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
|
||||||
|
style={{ backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading…</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => screenshotInputRef.current?.click()}
|
||||||
|
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
aria-label="Add screenshot"
|
||||||
|
>
|
||||||
|
<span className="text-xl">+</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={screenshotInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleScreenshotUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<TagSelector itemKey={game.item_key!} onTagsChanged={onTagsChanged} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Screenshot lightbox (z-60, sits above the modal) */}
|
{/* Lightbox */}
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 flex items-center justify-center"
|
className="fixed inset-0 flex items-center justify-center"
|
||||||
@@ -677,7 +591,7 @@ function DownloadButton({
|
|||||||
<span>↓</span>
|
<span>↓</span>
|
||||||
<span className="truncate">{primary.filename}</span>
|
<span className="truncate">{primary.filename}</span>
|
||||||
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
|
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import type { Game, GamePlatform, GameSeries, RatingOperator } from '@/types'
|
import type { Game, GamePlatform, GameSeries } from '@/types'
|
||||||
import { useDebounce } from '@/hooks/useDebounce'
|
|
||||||
import GameDetailModal from './GameDetailModal'
|
import GameDetailModal from './GameDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
|
|
||||||
@@ -59,10 +58,9 @@ function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GamesView({ libraryId, readOnly }: Props) {
|
export default function GamesView({ libraryId }: Props) {
|
||||||
const [items, setItems] = useState<(Game | GameSeries)[]>([])
|
const [items, setItems] = useState<(Game | GameSeries)[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -73,14 +71,8 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
|
||||||
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
|
||||||
const debouncedSearch = useDebounce(search, 200)
|
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
|
||||||
)
|
|
||||||
const [selectedGameIndex, setSelectedGameIndex] = useState<number | null>(null)
|
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -132,72 +124,29 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
? selectedSeries.games
|
? selectedSeries.games
|
||||||
: items
|
: items
|
||||||
|
|
||||||
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
const filtered = visibleItems.filter((item) => {
|
||||||
if (value === ratingValue && operator === ratingOperator) {
|
|
||||||
setRatingValue(null)
|
|
||||||
} else {
|
|
||||||
setRatingValue(value)
|
|
||||||
setRatingOperator(operator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = useMemo(() => visibleItems.filter((item) => {
|
|
||||||
if ('games' in item) {
|
if ('games' in item) {
|
||||||
if (debouncedSearch) {
|
const searchMatch = !search ||
|
||||||
const q = debouncedSearch.toLowerCase()
|
item.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
const searchMatch =
|
item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase()))
|
||||||
item.title.toLowerCase().includes(q) ||
|
if (!searchMatch) return false
|
||||||
item.games.some((g) =>
|
|
||||||
g.title.toLowerCase().includes(q) ||
|
|
||||||
(g.aiDescription?.toLowerCase().includes(q) ?? false) ||
|
|
||||||
(g.extractedText?.toLowerCase().includes(q) ?? false) ||
|
|
||||||
(g.extractedTextTranslated?.toLowerCase().includes(q) ?? false)
|
|
||||||
)
|
|
||||||
if (!searchMatch) return false
|
|
||||||
}
|
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
if (!item.games.some((g) => {
|
return item.games.some((g) => {
|
||||||
const gameTags = assignments[g.item_key!] ?? []
|
const gameTags = assignments[g.item_key!] ?? []
|
||||||
return [...selectedTagIds].every((id) => gameTags.includes(id))
|
return [...selectedTagIds].every((id) => gameTags.includes(id))
|
||||||
})) return false
|
})
|
||||||
}
|
|
||||||
if (ratingValue !== null) {
|
|
||||||
if (!item.games.some((g) => {
|
|
||||||
const r = g.userRating
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte') return r >= ratingValue
|
|
||||||
if (ratingOperator === 'eq') return r === ratingValue
|
|
||||||
if (ratingOperator === 'lte') return r <= ratingValue
|
|
||||||
return false
|
|
||||||
})) return false
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Standalone Game
|
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
if (debouncedSearch) {
|
|
||||||
const q = debouncedSearch.toLowerCase()
|
|
||||||
const g = item as Game
|
|
||||||
if (![g.title, g.aiDescription, g.extractedText, g.extractedTextTranslated]
|
|
||||||
.some((f) => f?.toLowerCase().includes(q))) return false
|
|
||||||
}
|
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const gameTags = assignments[item.item_key!] ?? []
|
const gameTags = assignments[item.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
if (ratingValue !== null) {
|
|
||||||
const r = (item as Game).userRating
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte' && r < ratingValue) return false
|
|
||||||
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
|
||||||
if (ratingOperator === 'lte' && r > ratingValue) return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}), [visibleItems, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
})
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
const filteredGames: Game[] = filtered.flatMap((item) =>
|
|
||||||
'games' in item ? item.games : [item as Game]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -226,9 +175,6 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
ratingValue={ratingValue}
|
|
||||||
ratingOperator={ratingOperator}
|
|
||||||
onRatingChange={handleRatingChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -274,7 +220,7 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
<GameCard
|
<GameCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
game={item}
|
game={item}
|
||||||
onClick={() => { setSelected(item); setSelectedGameIndex(filteredGames.indexOf(item)) }}
|
onClick={() => setSelected(item)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -285,19 +231,11 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
<GameDetailModal
|
<GameDetailModal
|
||||||
game={selected}
|
game={selected}
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
readOnly={readOnly}
|
onClose={() => setSelected(null)}
|
||||||
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
|
|
||||||
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
|
|
||||||
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
|
|
||||||
: undefined}
|
|
||||||
onNext={selectedGameIndex !== null && selectedGameIndex < filteredGames.length - 1
|
|
||||||
? () => { const g = filteredGames[selectedGameIndex + 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex + 1) }
|
|
||||||
: undefined}
|
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
onCoverUploaded={() => fetchGames(true)}
|
onCoverUploaded={() => fetchGames(true)}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setSelectedGameIndex(null)
|
|
||||||
fetchGames()
|
fetchGames()
|
||||||
fetchAssignments()
|
fetchAssignments()
|
||||||
}}
|
}}
|
||||||
@@ -351,7 +289,6 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
|
|||||||
const seriesPlatforms: GamePlatform[] = [
|
const seriesPlatforms: GamePlatform[] = [
|
||||||
...new Set(series.games.flatMap((g) => g.platforms)),
|
...new Set(series.games.flatMap((g) => g.platforms)),
|
||||||
]
|
]
|
||||||
const resolvedCover = series.coverUrl ?? series.games[0]?.coverUrl ?? null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -368,9 +305,9 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
{resolvedCover ? (
|
{series.coverUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={resolvedCover} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
|
<img src={series.coverUrl} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
@@ -12,49 +12,29 @@ interface Props {
|
|||||||
itemKey?: string
|
itemKey?: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onAiTag?: () => Promise<void>
|
onAiTag?: () => Promise<void>
|
||||||
showTags?: boolean
|
|
||||||
onShowTagsChange?: (v: boolean) => void
|
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
|
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
const [showTags, setShowTags] = useState(false)
|
||||||
const showTags = showTagsProp ?? showTagsLocal
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
|
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||||
|
|
||||||
// Text extraction state
|
// Text extraction state
|
||||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
const [extractPending, setExtractPending] = useState(false)
|
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
const [retranslating, setRetranslating] = useState(false)
|
const [retranslating, setRetranslating] = useState(false)
|
||||||
const [translatePending, setTranslatePending] = useState(false)
|
|
||||||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||||
const [savingText, setSavingText] = useState(false)
|
const [savingText, setSavingText] = useState(false)
|
||||||
const [sourceLanguage, setSourceLanguage] = useState('')
|
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||||
|
|
||||||
// Description state
|
|
||||||
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
|
||||||
const [editedDescription, setEditedDescription] = useState<string>('')
|
|
||||||
const [savingDesc, setSavingDesc] = useState(false)
|
|
||||||
const [generatingDesc, setGeneratingDesc] = useState(false)
|
|
||||||
const [descPending, setDescPending] = useState(false)
|
|
||||||
const [descError, setDescError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// OCR settings
|
|
||||||
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
|
||||||
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
|
||||||
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
|
||||||
|
|
||||||
// Text overlay state
|
// Text overlay state
|
||||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
|
|
||||||
// Polling ref
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
const touchStartX = useRef<number | null>(null)
|
|
||||||
|
|
||||||
// Determine if this is an image file (for text extraction controls)
|
// Determine if this is an image file (for text extraction controls)
|
||||||
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
||||||
|
|
||||||
@@ -62,94 +42,28 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||||
|
|
||||||
// Fetch existing AI fields on mount / item change
|
// Fetch existing AI fields on mount / item change
|
||||||
const fetchAiFields = useCallback(() => {
|
useEffect(() => {
|
||||||
if (!itemKey) return Promise.resolve()
|
if (!itemKey) return
|
||||||
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||||
setExtractedText(data.extractedText)
|
setExtractedText(data.extractedText)
|
||||||
setEditedExtractedText(data.extractedText ?? '')
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
setAiDescription(data.aiDescription)
|
|
||||||
setEditedDescription(data.aiDescription ?? '')
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [itemKey])
|
}, [itemKey])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAiFields()
|
|
||||||
fetch('/api/ai-settings/ocr')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
|
||||||
setOcrMode(d.ocrMode)
|
|
||||||
setDefaultOcrLanguages(d.ocrLanguages)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
return () => {
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
|
||||||
}
|
|
||||||
}, [fetchAiFields])
|
|
||||||
|
|
||||||
// Start polling fields every 2s until data changes or 5-min timeout
|
|
||||||
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null, snapshotDesc: string | null) => {
|
|
||||||
if (!itemKey) return
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
|
||||||
const deadline = Date.now() + 5 * 60 * 1000
|
|
||||||
pollRef.current = setInterval(async () => {
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
clearInterval(pollRef.current!)
|
|
||||||
pollRef.current = null
|
|
||||||
setExtractPending(false)
|
|
||||||
setTranslatePending(false)
|
|
||||||
setDescPending(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
|
||||||
const data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null } = await r.json()
|
|
||||||
const textChanged = data.extractedText !== snapshotText
|
|
||||||
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
|
|
||||||
const descChanged = data.aiDescription !== snapshotDesc
|
|
||||||
if (textChanged || translationChanged || descChanged) {
|
|
||||||
clearInterval(pollRef.current!)
|
|
||||||
pollRef.current = null
|
|
||||||
setExtractedText(data.extractedText)
|
|
||||||
setEditedExtractedText(data.extractedText ?? '')
|
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
|
||||||
setAiDescription(data.aiDescription)
|
|
||||||
setEditedDescription(data.aiDescription ?? '')
|
|
||||||
setExtractPending(false)
|
|
||||||
setTranslatePending(false)
|
|
||||||
setDescPending(false)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, 2000)
|
|
||||||
}, [itemKey])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
if (e.key === 'ArrowLeft') onPrev?.()
|
if (e.key === 'ArrowLeft') onPrev?.()
|
||||||
if (e.key === 'ArrowRight') onNext?.()
|
if (e.key === 'ArrowRight') onNext?.()
|
||||||
}
|
}
|
||||||
const handleTouchStart = (e: TouchEvent) => {
|
|
||||||
touchStartX.current = e.touches[0].clientX
|
|
||||||
}
|
|
||||||
const handleTouchEnd = (e: TouchEvent) => {
|
|
||||||
if (touchStartX.current === null) return
|
|
||||||
const delta = touchStartX.current - e.changedTouches[0].clientX
|
|
||||||
if (delta > 50) onNext?.()
|
|
||||||
else if (delta < -50) onPrev?.()
|
|
||||||
touchStartX.current = null
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleKey)
|
document.addEventListener('keydown', handleKey)
|
||||||
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
|
||||||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.removeEventListener('touchstart', handleTouchStart)
|
|
||||||
document.removeEventListener('touchend', handleTouchEnd)
|
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, onPrev, onNext])
|
}, [onClose, onPrev, onNext])
|
||||||
@@ -158,189 +72,24 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateDescription = async () => {
|
|
||||||
if (!itemKey) return
|
|
||||||
setGeneratingDesc(true)
|
|
||||||
setDescError(null)
|
|
||||||
setDescPending(false)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai-tagging/describe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ itemKey }),
|
|
||||||
})
|
|
||||||
if (res.status === 202) {
|
|
||||||
setDescPending(true)
|
|
||||||
startPolling(extractedText, translatedText, aiDescription)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
|
|
||||||
}
|
|
||||||
const { description } = await res.json()
|
|
||||||
setAiDescription(description)
|
|
||||||
} catch (err) {
|
|
||||||
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
|
|
||||||
setTimeout(() => setDescError(null), 4000)
|
|
||||||
} finally {
|
|
||||||
setGeneratingDesc(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const callExtract = async (modeOverride: string) => {
|
|
||||||
setExtracting(true)
|
|
||||||
setExtractError(null)
|
|
||||||
setExtractPending(false)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
itemKey,
|
|
||||||
ocrMode: modeOverride,
|
|
||||||
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (res.status === 202) {
|
|
||||||
setExtractPending(true)
|
|
||||||
startPolling(extractedText, translatedText, aiDescription)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
|
||||||
}
|
|
||||||
const result = await res.json()
|
|
||||||
setExtractedText(result.extractedText || null)
|
|
||||||
setEditedExtractedText(result.extractedText || '')
|
|
||||||
setTranslatedText(result.translatedText || null)
|
|
||||||
} catch (err) {
|
|
||||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
|
||||||
} finally {
|
|
||||||
setExtracting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 overflow-hidden"
|
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
{/* Toolbar */}
|
||||||
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
|
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
||||||
|
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
|
{name}
|
||||||
<div className="relative flex-1 min-h-0 min-w-0">
|
</span>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<img
|
{/* Text overlay button — only shown when extracted text exists */}
|
||||||
src={url}
|
{extractedText && (
|
||||||
alt={name}
|
|
||||||
className="absolute inset-0 w-full h-full object-contain"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Prev / Next */}
|
|
||||||
{onPrev && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Previous"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onNext && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Text overlay */}
|
|
||||||
{showTextOverlay && displayText && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{extractedText && translatedText && (
|
|
||||||
<div className="flex justify-end mb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowOriginal((v) => !v)}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
|
||||||
>
|
|
||||||
{showOriginal ? 'Show Translation' : 'Show Original'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
|
||||||
{displayText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Floating controls ── */}
|
|
||||||
|
|
||||||
{/* Filename pill — bottom-left */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
|
||||||
>
|
|
||||||
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags + Close — top-right */}
|
|
||||||
<div
|
|
||||||
className="absolute top-4 right-4 flex items-center gap-1.5"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{itemKey && !showTags && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
|
||||||
aria-label="Show tags"
|
|
||||||
title="Tags"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!showTags && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text display button — bottom-right, hidden when panel open */}
|
|
||||||
{!showTags && extractedText && (
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||||||
className={`absolute bottom-4 right-4 ${smallBtn}`}
|
className="w-12 h-12 rounded-full flex items-center justify-center transition-colors"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
||||||
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
||||||
@@ -354,156 +103,208 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||||||
title="Display text"
|
title="Display text"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
<line x1="3" y1="12" x2="15" y2="12"/>
|
<line x1="3" y1="12" x2="15" y2="12"/>
|
||||||
<line x1="3" y1="18" x2="18" y2="18"/>
|
<line x1="3" y1="18" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
{itemKey && (
|
||||||
|
<button
|
||||||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
||||||
{showTags && (
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
<MediaTagPanel
|
style={{
|
||||||
itemKey={itemKey!}
|
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
||||||
onHide={() => setShowTags(false)}
|
color: showTags ? '#fff' : 'var(--text-primary)',
|
||||||
onClose={onClose}
|
fontSize: '1.5rem',
|
||||||
onTagsChanged={onTagsChanged}
|
}}
|
||||||
onAiTag={readOnly ? undefined : onAiTag}
|
onMouseEnter={(e) => {
|
||||||
readOnly={readOnly}
|
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
||||||
|
title="Tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onAiTag && (
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setAiTagging(true)
|
||||||
|
setAiTagError(null)
|
||||||
|
try {
|
||||||
|
await onAiTag()
|
||||||
|
setTagRefreshKey((k) => k + 1)
|
||||||
|
onTagsChanged?.()
|
||||||
|
} catch (err) {
|
||||||
|
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||||
|
setTimeout(() => setAiTagError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setAiTagging(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag this image"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? (
|
||||||
|
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}>⟳</span>
|
||||||
|
) : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||||||
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
{/* Description section */}
|
✕
|
||||||
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
</button>
|
||||||
<div className="flex items-center justify-between mb-2">
|
</div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
</div>
|
||||||
Description
|
|
||||||
|
{showTags ? (
|
||||||
|
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
|
||||||
|
{/* Image */}
|
||||||
|
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={name}
|
||||||
|
className="max-w-full max-h-full w-auto h-auto object-contain rounded-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{onPrev && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNext && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Text overlay */}
|
||||||
|
{showTextOverlay && displayText && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{extractedText && translatedText && (
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOriginal((v) => !v)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
>
|
||||||
|
{showOriginal ? 'Show Translation' : 'Show Original'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||||
|
{displayText}
|
||||||
</p>
|
</p>
|
||||||
<button
|
|
||||||
onClick={handleGenerateDescription}
|
|
||||||
disabled={generatingDesc || descPending}
|
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
|
||||||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
}}
|
|
||||||
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
|
||||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
|
||||||
>
|
|
||||||
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
)}
|
||||||
value={editedDescription}
|
</div>
|
||||||
onChange={(e) => setEditedDescription(e.target.value)}
|
{/* Tag panel */}
|
||||||
placeholder="No description yet…"
|
<div
|
||||||
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
||||||
style={{
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
backgroundColor: 'var(--background)',
|
onClick={(e) => e.stopPropagation()}
|
||||||
border: '1px solid var(--border)',
|
>
|
||||||
color: 'var(--text-primary)',
|
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
minHeight: '3.5rem',
|
Tags
|
||||||
maxHeight: '8rem',
|
</p>
|
||||||
fontFamily: 'inherit',
|
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{editedDescription !== (aiDescription ?? '') && (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
setSavingDesc(true)
|
|
||||||
try {
|
|
||||||
await fetch('/api/ai-tagging/fields', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
|
||||||
})
|
|
||||||
setAiDescription(editedDescription)
|
|
||||||
} finally {
|
|
||||||
setSavingDesc(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={savingDesc}
|
|
||||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{savingDesc ? '⟳ Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text extraction section — only for images */}
|
{/* Text extraction section — only for images */}
|
||||||
{isImage && (
|
{isImage && (
|
||||||
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
Text Extraction
|
||||||
Text Extraction
|
</p>
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => callExtract('llm')}
|
|
||||||
disabled={extracting || extractPending}
|
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
|
||||||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
}}
|
|
||||||
aria-label="Extract text with AI"
|
|
||||||
title="Extract with AI (skips OCR)"
|
|
||||||
>
|
|
||||||
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<button
|
||||||
<button
|
onClick={async () => {
|
||||||
onClick={() => callExtract('tesseract')}
|
setExtracting(true)
|
||||||
disabled={extracting || extractPending}
|
setExtractError(null)
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
try {
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
onMouseEnter={(e) => {
|
method: 'POST',
|
||||||
if (!extracting && !extractPending) {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
body: JSON.stringify({ itemKey }),
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
||||||
}
|
}
|
||||||
}}
|
if (res.status === 202) {
|
||||||
onMouseLeave={(e) => {
|
setExtractError('Queued — check AI Integrations for progress')
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
return
|
||||||
}}
|
}
|
||||||
>
|
const result = await res.json()
|
||||||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
setExtractedText(result.extractedText || null)
|
||||||
</button>
|
setEditedExtractedText(result.extractedText || '')
|
||||||
<input
|
setTranslatedText(result.translatedText || null)
|
||||||
type="text"
|
} catch (err) {
|
||||||
value={ocrLanguageInput}
|
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
||||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
placeholder={defaultOcrLanguages}
|
} finally {
|
||||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
setExtracting(false)
|
||||||
style={{
|
}
|
||||||
backgroundColor: 'var(--background)',
|
}}
|
||||||
border: '1px solid var(--border)',
|
disabled={extracting}
|
||||||
color: 'var(--text-primary)',
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2"
|
||||||
width: 120,
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
}}
|
onMouseEnter={(e) => {
|
||||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
if (!extracting) {
|
||||||
/>
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
</div>
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
||||||
|
</button>
|
||||||
|
|
||||||
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
|
{extractError && (
|
||||||
|
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{extractedText && (
|
{extractedText && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -562,6 +363,34 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<<<<<<< Updated upstream
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
|
placeholder="Source lang…"
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
width: 100,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setRetranslating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||||
|
=======
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -579,59 +408,116 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRetranslating(true)
|
setRetranslating(true)
|
||||||
setTranslatePending(false)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||||
})
|
})
|
||||||
if (res.status === 202) {
|
|
||||||
setTranslatePending(true)
|
|
||||||
startPolling(extractedText, translatedText, aiDescription)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||||
}
|
}
|
||||||
const result = await res.json()
|
if (res.status === 202) {
|
||||||
setTranslatedText(result.translatedText || null)
|
setExtractError('Queued — check AI Integrations for progress')
|
||||||
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
|
} else {
|
||||||
|
const result = await res.json()
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRetranslating(false)
|
||||||
|
>>>>>>> Stashed changes
|
||||||
|
}
|
||||||
|
if (res.status !== 202) {
|
||||||
|
const result = await res.json()
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setRetranslating(false)
|
setRetranslating(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={retranslating || translatePending}
|
disabled={retranslating}
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
style={{
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
|
||||||
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!retranslating && !translatePending) {
|
if (!retranslating) {
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!translatePending) {
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</MediaTagPanel>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={name}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{onPrev && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNext && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Text overlay */}
|
||||||
|
{showTextOverlay && displayText && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{extractedText && translatedText && (
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOriginal((v) => !v)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
>
|
||||||
|
{showOriginal ? 'Show Translation' : 'Show Original'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import type { DirectoryListing, FileEntry, RatingOperator } from '@/types'
|
import type { DirectoryListing, FileEntry } from '@/types'
|
||||||
import { useDebounce } from '@/hooks/useDebounce'
|
|
||||||
import VideoPlayerModal from './VideoPlayerModal'
|
import VideoPlayerModal from './VideoPlayerModal'
|
||||||
import ImageLightbox from './ImageLightbox'
|
import ImageLightbox from './ImageLightbox'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
@@ -12,9 +11,7 @@ import { isBrowserPlayable } from '@/lib/browser-media'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
libraryName: string
|
|
||||||
initialPath: string
|
initialPath: string
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalState =
|
type ModalState =
|
||||||
@@ -24,22 +21,18 @@ type ModalState =
|
|||||||
|
|
||||||
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
||||||
|
|
||||||
export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) {
|
export default function MixedView({ libraryId, initialPath }: Props) {
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath)
|
const [currentPath, setCurrentPath] = useState(initialPath)
|
||||||
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [modal, setModal] = useState<ModalState>(null)
|
const [modal, setModal] = useState<ModalState>(null)
|
||||||
const [modalShowTags, setModalShowTags] = useState(false)
|
|
||||||
const [tagPanel, setTagPanel] = useState<TagPanelState>(null)
|
const [tagPanel, setTagPanel] = useState<TagPanelState>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const debouncedSearch = useDebounce(search, 200)
|
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
|
||||||
)
|
|
||||||
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
|
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
|
||||||
const [recursiveLoading, setRecursiveLoading] = useState(false)
|
const [recursiveLoading, setRecursiveLoading] = useState(false)
|
||||||
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
|
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
|
||||||
@@ -90,9 +83,6 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
setDoomScrollLoading(false)
|
setDoomScrollLoading(false)
|
||||||
}, [currentPath])
|
}, [currentPath])
|
||||||
|
|
||||||
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
|
||||||
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
|
||||||
|
|
||||||
const fetchAssignments = useCallback(() => {
|
const fetchAssignments = useCallback(() => {
|
||||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -102,29 +92,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
useEffect(() => {
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
fetch('/api/ai-settings/ocr')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
|
||||||
setOcrMode(d.ocrMode)
|
|
||||||
setDefaultOcrLanguages(d.ocrLanguages)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
|
||||||
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
|
||||||
|
|
||||||
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
|
||||||
if (value === ratingValue && operator === ratingOperator) {
|
|
||||||
setRatingValue(null)
|
|
||||||
} else {
|
|
||||||
setRatingValue(value)
|
|
||||||
setRatingOperator(operator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
|
||||||
|
|
||||||
const fetchRecursive = useCallback(() => {
|
const fetchRecursive = useCallback(() => {
|
||||||
if (recursiveLoaded || recursiveLoading) return
|
if (recursiveLoaded || recursiveLoading) return
|
||||||
@@ -169,31 +137,14 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
|
|
||||||
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||||
|
|
||||||
const filteredEntries = useMemo(() => sourceEntries.filter((entry) => {
|
const filteredEntries = sourceEntries.filter((entry) => {
|
||||||
if (debouncedSearch) {
|
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
const q = debouncedSearch.toLowerCase()
|
|
||||||
const matchesSearch = [
|
|
||||||
entry.name,
|
|
||||||
entry.aiDescription,
|
|
||||||
entry.extractedText,
|
|
||||||
entry.extractedTextTranslated,
|
|
||||||
].some((field) => field?.toLowerCase().includes(q))
|
|
||||||
if (!matchesSearch) return false
|
|
||||||
}
|
|
||||||
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
||||||
const entryTags = assignments[itemKeyFor(entry)] ?? []
|
const entryTags = assignments[itemKeyFor(entry)] ?? []
|
||||||
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
if (ratingValue !== null && entry.type !== 'directory') {
|
|
||||||
const r = entry.userRating ?? null
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte' && r < ratingValue) return false
|
|
||||||
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
|
||||||
if (ratingOperator === 'lte' && r > ratingValue) return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
})
|
||||||
}), [sourceEntries, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
|
||||||
|
|
||||||
const mediaEntries = filteredEntries.filter(
|
const mediaEntries = filteredEntries.filter(
|
||||||
(e) => e.mediaType === 'video' || e.mediaType === 'image'
|
(e) => e.mediaType === 'video' || e.mediaType === 'image'
|
||||||
@@ -368,29 +319,18 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
ratingValue={ratingValue}
|
|
||||||
ratingOperator={ratingOperator}
|
|
||||||
onRatingChange={handleRatingChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
|
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Libraries
|
|
||||||
</a>
|
|
||||||
<span style={{ color: 'var(--border)' }}>/</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => loadPath('')}
|
onClick={() => loadPath('')}
|
||||||
className="transition-colors"
|
className="transition-colors"
|
||||||
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
|
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
{libraryName}
|
Root
|
||||||
</button>
|
</button>
|
||||||
{breadcrumbs.map((segment, i) => {
|
{breadcrumbs.map((segment, i) => {
|
||||||
const isLast = i === breadcrumbs.length - 1
|
const isLast = i === breadcrumbs.length - 1
|
||||||
@@ -447,8 +387,6 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
onOpen={handleEntry}
|
onOpen={handleEntry}
|
||||||
onTag={handleTagEntry}
|
onTag={handleTagEntry}
|
||||||
ocrMode={ocrMode}
|
|
||||||
defaultOcrLanguages={defaultOcrLanguages}
|
|
||||||
onAiTag={async (e) => {
|
onAiTag={async (e) => {
|
||||||
const itemKey = itemKeyFor(e)
|
const itemKey = itemKeyFor(e)
|
||||||
const res = await fetch('/api/ai-tagging', {
|
const res = await fetch('/api/ai-tagging', {
|
||||||
@@ -463,7 +401,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
fetchAssignments()
|
fetchAssignments()
|
||||||
setFilterRefreshKey((k) => k + 1)
|
setFilterRefreshKey((k) => k + 1)
|
||||||
}}
|
}}
|
||||||
onExtractText={async (e, ocrLanguages) => {
|
onExtractText={async (e) => {
|
||||||
if (e.type === 'directory') {
|
if (e.type === 'directory') {
|
||||||
// Bulk extract for directory
|
// Bulk extract for directory
|
||||||
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
@@ -482,7 +420,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }),
|
body: JSON.stringify({ itemKey }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
@@ -515,31 +453,6 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTranslate={async (e) => {
|
|
||||||
if (e.type === 'directory') {
|
|
||||||
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
|
||||||
const res = await fetch('/api/ai-tagging/translate-bulk', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ libraryId, path: dirRel }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const itemKey = itemKeyFor(e)
|
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ itemKey }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={(e) => {
|
onDelete={(e) => {
|
||||||
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
||||||
@@ -580,13 +493,10 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
name={modal.name}
|
name={modal.name}
|
||||||
itemKey={modal.itemKey}
|
itemKey={modal.itemKey}
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
onClose={() => { setModal(null); setModalShowTags(false) }}
|
onClose={() => setModal(null)}
|
||||||
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
||||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||||
showTags={modalShowTags}
|
onAiTag={modal.itemKey ? async () => {
|
||||||
onShowTagsChange={setModalShowTags}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onAiTag={!readOnly && modal.itemKey ? async () => {
|
|
||||||
const res = await fetch('/api/ai-tagging', {
|
const res = await fetch('/api/ai-tagging', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -607,13 +517,10 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
name={modal.name}
|
name={modal.name}
|
||||||
itemKey={modal.itemKey}
|
itemKey={modal.itemKey}
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
onClose={() => { setModal(null); setModalShowTags(false) }}
|
onClose={() => setModal(null)}
|
||||||
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
||||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||||
showTags={modalShowTags}
|
onAiTag={async () => {
|
||||||
onShowTagsChange={setModalShowTags}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onAiTag={readOnly ? undefined : async () => {
|
|
||||||
const res = await fetch('/api/ai-tagging', {
|
const res = await fetch('/api/ai-tagging', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -675,7 +582,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate, ocrMode, defaultOcrLanguages }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry, ocrLanguages?: string) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void>; ocrMode?: string | null; defaultOcrLanguages?: string }) {
|
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void> }) {
|
||||||
type ImgState = 'loading' | 'loaded' | 'error'
|
type ImgState = 'loading' | 'loaded' | 'error'
|
||||||
const [imgState, setImgState] = useState<ImgState>(
|
const [imgState, setImgState] = useState<ImgState>(
|
||||||
entry.thumbnailUrl ? 'loading' : 'error'
|
entry.thumbnailUrl ? 'loading' : 'error'
|
||||||
@@ -694,10 +601,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
const [textExtractError, setTextExtractError] = useState<string | null>(null)
|
const [textExtractError, setTextExtractError] = useState<string | null>(null)
|
||||||
const [describing, setDescribing] = useState(false)
|
const [describing, setDescribing] = useState(false)
|
||||||
const [describeError, setDescribeError] = useState<string | null>(null)
|
const [describeError, setDescribeError] = useState<string | null>(null)
|
||||||
const [translating, setTranslating] = useState(false)
|
|
||||||
const [translateError, setTranslateError] = useState<string | null>(null)
|
|
||||||
const [showOcrPrompt, setShowOcrPrompt] = useState(false)
|
|
||||||
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
@@ -812,7 +715,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Kebab menu — bottom-right, shown on hover */}
|
{/* Kebab menu — bottom-right, shown on hover */}
|
||||||
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory')) || (onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText)) && (
|
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && (
|
||||||
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
|
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
|
||||||
@@ -887,21 +790,16 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
📝 Describe Folder
|
📝 Describe Folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExtractText && entry.mediaType === 'image' && !showOcrPrompt && (
|
{onExtractText && entry.mediaType === 'image' && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (ocrMode && ocrMode !== 'llm') {
|
setMenuOpen(false)
|
||||||
setOcrLanguageInput('')
|
setTextExtracting(true)
|
||||||
setShowOcrPrompt(true)
|
setTextExtractError(null)
|
||||||
} else {
|
onExtractText(entry)
|
||||||
setMenuOpen(false)
|
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||||
setTextExtracting(true)
|
.finally(() => setTextExtracting(false))
|
||||||
setTextExtractError(null)
|
|
||||||
onExtractText(entry)
|
|
||||||
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
|
||||||
.finally(() => setTextExtracting(false))
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={textExtracting}
|
disabled={textExtracting}
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||||
@@ -912,57 +810,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
🔍 Extract Text
|
🔍 Extract Text
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExtractText && entry.mediaType === 'image' && showOcrPrompt && (
|
|
||||||
<div className="px-4 py-2 flex flex-col gap-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>OCR language</p>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="text"
|
|
||||||
value={ocrLanguageInput}
|
|
||||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape') { setShowOcrPrompt(false) }
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
setShowOcrPrompt(false)
|
|
||||||
setMenuOpen(false)
|
|
||||||
setTextExtracting(true)
|
|
||||||
setTextExtractError(null)
|
|
||||||
onExtractText(entry, ocrLanguageInput.trim() || undefined)
|
|
||||||
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
|
||||||
.finally(() => setTextExtracting(false))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={defaultOcrLanguages ?? 'eng'}
|
|
||||||
className="text-xs px-2 py-1 rounded-lg outline-none w-full"
|
|
||||||
style={{ backgroundColor: 'var(--background)', border: '1px solid var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowOcrPrompt(false)
|
|
||||||
setMenuOpen(false)
|
|
||||||
setTextExtracting(true)
|
|
||||||
setTextExtractError(null)
|
|
||||||
onExtractText(entry, ocrLanguageInput.trim() || undefined)
|
|
||||||
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
|
||||||
.finally(() => setTextExtracting(false))
|
|
||||||
}}
|
|
||||||
className="text-xs px-2 py-1 rounded-lg"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
Extract
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowOcrPrompt(false)}
|
|
||||||
className="text-xs px-2 py-1"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{onExtractText && entry.type === 'directory' && (
|
{onExtractText && entry.type === 'directory' && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -983,26 +830,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
🔍 Extract Text for Folder
|
🔍 Extract Text for Folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setMenuOpen(false)
|
|
||||||
setTranslating(true)
|
|
||||||
setTranslateError(null)
|
|
||||||
onTranslate(entry)
|
|
||||||
.catch((err) => setTranslateError(err instanceof Error ? err.message : 'Translation failed'))
|
|
||||||
.finally(() => setTranslating(false))
|
|
||||||
}}
|
|
||||||
disabled={translating}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onRename && (
|
{onRename && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1102,28 +929,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Translation status overlay */}
|
|
||||||
{(translating || translateError) && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
|
|
||||||
style={{ backgroundColor: translateError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<span style={{ color: translateError ? '#fca5a5' : 'var(--text-secondary)' }}>
|
|
||||||
{translateError ?? 'Translating…'}
|
|
||||||
</span>
|
|
||||||
{translateError && (
|
|
||||||
<button
|
|
||||||
onClick={() => setTranslateError(null)}
|
|
||||||
className="ml-2 underline text-xs"
|
|
||||||
style={{ color: '#fca5a5' }}
|
|
||||||
>
|
|
||||||
dismiss
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation overlay */}
|
{/* Delete confirmation overlay */}
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
import { useUserSettings } from '@/hooks/useUserSettings'
|
import { useUserSettings } from '@/hooks/useUserSettings'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,21 +14,18 @@ interface Props {
|
|||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onAiTag?: () => Promise<void>
|
onAiTag?: () => Promise<void>
|
||||||
context?: 'mixed' | 'movies' | 'tv'
|
context?: 'mixed' | 'movies' | 'tv'
|
||||||
showTags?: boolean
|
|
||||||
onShowTagsChange?: (v: boolean) => void
|
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
|
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed' }: Props) {
|
||||||
const settings = useUserSettings()
|
const settings = useUserSettings()
|
||||||
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
||||||
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
||||||
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
|
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
|
||||||
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
const [showTags, setShowTags] = useState(false)
|
||||||
const showTags = showTagsProp ?? showTagsLocal
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
|
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -48,58 +45,93 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 overflow-hidden"
|
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
{/* Toolbar */}
|
||||||
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}>
|
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
||||||
|
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{itemKey && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: showTags ? '#fff' : 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
||||||
|
title="Tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onAiTag && (
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setAiTagging(true)
|
||||||
|
setAiTagError(null)
|
||||||
|
try {
|
||||||
|
await onAiTag()
|
||||||
|
setTagRefreshKey((k) => k + 1)
|
||||||
|
onTagsChanged?.()
|
||||||
|
} catch (err) {
|
||||||
|
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||||
|
setTimeout(() => setAiTagError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setAiTagging(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag this video"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? (
|
||||||
|
<span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span>
|
||||||
|
) : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Video column ── */}
|
{showTags ? (
|
||||||
<div className="flex flex-col flex-1 min-h-0 min-w-0 relative">
|
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
|
||||||
|
{/* Video */}
|
||||||
{/* Toolbar — scoped to this column's width */}
|
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
|
||||||
<div className="flex items-center justify-between px-3 py-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<span className="text-sm truncate mr-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
{itemKey && !showTags && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTags(true)}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
|
||||||
aria-label="Show tags"
|
|
||||||
title="Tags"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!showTags && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video area — single element, never remounts on panel toggle */}
|
|
||||||
<div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<video
|
<video
|
||||||
key={url}
|
key={url}
|
||||||
src={url}
|
src={url}
|
||||||
@@ -107,18 +139,60 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
loop={loop}
|
loop={loop}
|
||||||
playsInline
|
className="w-full h-full object-contain rounded-lg"
|
||||||
className="w-full h-full object-contain"
|
|
||||||
style={{ backgroundColor: '#000' }}
|
style={{ backgroundColor: '#000' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
{onPrev && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNext && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Tag panel */}
|
||||||
{/* Prev/Next — positioned relative to the full column height (incl. toolbar)
|
<div
|
||||||
so they align with ImageLightbox's buttons which span the full viewport */}
|
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
||||||
|
<video
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
controls
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
muted={muted}
|
||||||
|
loop={loop}
|
||||||
|
className="w-full h-full max-w-4xl object-contain rounded-lg"
|
||||||
|
style={{ backgroundColor: '#000' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
{onPrev && (
|
{onPrev && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
aria-label="Previous"
|
aria-label="Previous"
|
||||||
>
|
>
|
||||||
@@ -128,7 +202,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
{onNext && (
|
{onNext && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
>
|
>
|
||||||
@@ -136,19 +210,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
|
|
||||||
{showTags && (
|
|
||||||
<MediaTagPanel
|
|
||||||
itemKey={itemKey!}
|
|
||||||
onHide={() => setShowTags(false)}
|
|
||||||
onClose={onClose}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
onAiTag={readOnly ? undefined : onAiTag}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie } from '@/types'
|
||||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,10 +14,9 @@ interface Props {
|
|||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onDeleted: (movieId: string) => void
|
onDeleted: (movieId: string) => void
|
||||||
onMetadataRefreshed?: () => void
|
onMetadataRefreshed?: () => void
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
|
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
@@ -34,22 +32,15 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
const [renameName, setRenameName] = useState('')
|
const [renameName, setRenameName] = useState('')
|
||||||
const [renameError, setRenameError] = useState<string | null>(null)
|
const [renameError, setRenameError] = useState<string | null>(null)
|
||||||
const [renameSaving, setRenameSaving] = useState(false)
|
const [renameSaving, setRenameSaving] = useState(false)
|
||||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
|
|
||||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowLeft') { onPrev?.(); return }
|
|
||||||
if (e.key === 'ArrowRight') { onNext?.(); return }
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
if (confirming) { setConfirming(false); return }
|
if (confirming) { setConfirming(false); return }
|
||||||
if (warnRefresh) { setWarnRefresh(false); return }
|
if (warnRefresh) { setWarnRefresh(false); return }
|
||||||
if (editing) { setEditing(false); return }
|
if (editing) { setEditing(false); return }
|
||||||
if (renaming) { setRenaming(false); return }
|
if (renaming) { setRenaming(false); return }
|
||||||
if (showTagPanel) { setShowTagPanel(false); return }
|
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +50,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
|
}, [onClose, menuOpen, confirming, editing, warnRefresh, renaming])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,6 +132,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
|
|
||||||
const handleStartRename = () => {
|
const handleStartRename = () => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
|
// movie.id is the encoded folder name
|
||||||
setRenameName(decodeURIComponent(movie.id))
|
setRenameName(decodeURIComponent(movie.id))
|
||||||
setRenameError(null)
|
setRenameError(null)
|
||||||
setRenaming(true)
|
setRenaming(true)
|
||||||
@@ -195,387 +187,339 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 overflow-hidden"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
<div
|
||||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* ── Left pane — relative container for floating controls ── */}
|
{/* Prev / Next buttons on the detail card */}
|
||||||
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
{onPrev && (
|
||||||
{/* Scrollable card area */}
|
<button
|
||||||
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
onClick={onPrev}
|
||||||
<div
|
className="absolute top-3 left-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||||||
|
aria-label="Previous movie"
|
||||||
>
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNext && (
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="absolute top-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)', right: onPrev ? '3rem' : undefined }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||||||
|
aria-label="Next movie"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hero image */}
|
{/* Hero image */}
|
||||||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
{heroUrl ? (
|
{heroUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={heroUrl}
|
src={heroUrl}
|
||||||
alt={movie.title}
|
alt={movie.title}
|
||||||
className="w-full object-cover max-h-64"
|
className="w-full object-cover max-h-64"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-40 flex items-center justify-center text-5xl">🎬</div>
|
<div className="h-40 flex items-center justify-center text-5xl">🎬</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="p-5">
|
|
||||||
{/* Title row with kebab menu */}
|
|
||||||
<div className="flex items-start gap-2 mb-1">
|
|
||||||
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{movie.title}
|
|
||||||
</h2>
|
|
||||||
{movie.year && (
|
|
||||||
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{movie.year}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Kebab menu */}
|
|
||||||
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
aria-label="More options"
|
|
||||||
>
|
|
||||||
⋮
|
|
||||||
</button>
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleRefreshMetadata}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleStartEditing}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
Edit metadata
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleStartRename}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
Rename folder
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
|
||||||
style={{ color: '#fca5a5' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
Delete movie
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rename inline input */}
|
|
||||||
{renaming && (
|
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={renameName}
|
|
||||||
onChange={(e) => setRenameName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
|
|
||||||
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => setRenaming(false)}
|
|
||||||
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleRename}
|
|
||||||
disabled={renameSaving}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{renameSaving ? '…' : 'Rename'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editing ? (
|
|
||||||
<div className="flex flex-col gap-3 mb-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editForm.title}
|
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
|
||||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editForm.year}
|
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
|
|
||||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={editForm.plot}
|
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
|
|
||||||
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editForm.genres}
|
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
|
|
||||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => setEditing(false)}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveMetadata}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Meta row */}
|
|
||||||
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
|
||||||
{movie.rating !== null && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
||||||
★ {movie.rating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{movie.runtime !== null && (
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{movie.runtime} min
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{movie.genres.map((g) => (
|
|
||||||
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
||||||
{g}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{movie.plot && (
|
|
||||||
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{movie.plot}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* NFO refresh warning */}
|
|
||||||
{warnRefresh && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
|
||||||
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
|
|
||||||
>
|
|
||||||
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
|
|
||||||
Refreshing from NFO will overwrite your manual edits.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setWarnRefresh(false)}
|
|
||||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={doRefreshMetadata}
|
|
||||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
|
||||||
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
|
||||||
>
|
|
||||||
Overwrite
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmation banner */}
|
|
||||||
{confirming && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
|
||||||
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
|
||||||
>
|
|
||||||
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
|
||||||
Permanently delete this movie and all its files?
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirming(false)}
|
|
||||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirmDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
|
|
||||||
>
|
|
||||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assigned tags (read-only) above action buttons */}
|
|
||||||
{movie.item_key && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<AssignedTagBadges itemKey={movie.item_key} refreshKey={tagRefreshKey} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action buttons row: Play + Download */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPlaying(true)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
|
||||||
>
|
|
||||||
<span>▶</span>
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href={videoUrl}
|
|
||||||
download
|
|
||||||
className="flex items-center justify-center px-3 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
title="Download"
|
|
||||||
aria-label="Download"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating controls — tag + close */}
|
|
||||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{movie.item_key && !showTagPanel && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTagPanel(true)}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
||||||
aria-label="Show tags"
|
|
||||||
title="Tags"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prev / Next */}
|
|
||||||
{onPrev && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Previous"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onNext && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
{/* Info */}
|
||||||
{showTagPanel && (
|
<div className="p-5">
|
||||||
<MediaTagPanel
|
{/* Title row with kebab menu */}
|
||||||
itemKey={movie.item_key!}
|
<div className="flex items-start gap-2 mb-1">
|
||||||
onHide={() => setShowTagPanel(false)}
|
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
||||||
onClose={onClose}
|
{movie.title}
|
||||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
</h2>
|
||||||
readOnly={readOnly}
|
{movie.year && (
|
||||||
/>
|
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
)}
|
{movie.year}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Kebab menu */}
|
||||||
|
<div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleRefreshMetadata}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Edit metadata
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStartRename}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Rename folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Delete movie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename inline input */}
|
||||||
|
{renaming && (
|
||||||
|
<div className="flex flex-col gap-2 mb-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameName}
|
||||||
|
onChange={(e) => setRenameName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setRenaming(false)}
|
||||||
|
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRename}
|
||||||
|
disabled={renameSaving}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{renameSaving ? '…' : 'Rename'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.title}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editForm.year}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={editForm.plot}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.genres}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveMetadata}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Meta row */}
|
||||||
|
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
{movie.rating !== null && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
★ {movie.rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{movie.runtime !== null && (
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{movie.runtime} min
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{movie.genres.map((g) => (
|
||||||
|
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{movie.plot && (
|
||||||
|
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{movie.plot}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NFO refresh warning */}
|
||||||
|
{warnRefresh && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
|
||||||
|
>
|
||||||
|
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
|
||||||
|
Refreshing from NFO will overwrite your manual edits.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setWarnRefresh(false)}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={doRefreshMetadata}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
|
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
||||||
|
>
|
||||||
|
Overwrite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation banner */}
|
||||||
|
{confirming && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||||||
|
>
|
||||||
|
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||||||
|
Permanently delete this movie and all its files?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Play button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPlaying(true)}
|
||||||
|
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||||
|
>
|
||||||
|
<span>▶</span>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<TagSelector itemKey={movie.item_key!} onTagsChanged={onTagsChanged} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import type { Movie, RatingOperator } from '@/types'
|
import type { Movie } from '@/types'
|
||||||
import MovieDetailModal from './MovieDetailModal'
|
import MovieDetailModal from './MovieDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||||
import { isBrowserPlayable } from '@/lib/browser-media'
|
import { isBrowserPlayable } from '@/lib/browser-media'
|
||||||
import { useDebounce } from '@/hooks/useDebounce'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MoviesView({ libraryId, readOnly }: Props) {
|
export default function MoviesView({ libraryId }: Props) {
|
||||||
const [movies, setMovies] = useState<Movie[]>([])
|
const [movies, setMovies] = useState<Movie[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -21,13 +19,8 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
|
||||||
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
|
||||||
const debouncedSearch = useDebounce(search, 200)
|
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
|
||||||
)
|
|
||||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||||
|
|
||||||
@@ -62,34 +55,14 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
const filtered = movies.filter((movie) => {
|
||||||
if (value === ratingValue && operator === ratingOperator) {
|
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
setRatingValue(null)
|
|
||||||
} else {
|
|
||||||
setRatingValue(value)
|
|
||||||
setRatingOperator(operator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = useMemo(() => movies.filter((movie) => {
|
|
||||||
if (debouncedSearch) {
|
|
||||||
const q = debouncedSearch.toLowerCase()
|
|
||||||
if (![movie.title, movie.plot, movie.aiDescription, movie.extractedText, movie.extractedTextTranslated]
|
|
||||||
.some((f) => f?.toLowerCase().includes(q))) return false
|
|
||||||
}
|
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const movieTags = assignments[movie.item_key!] ?? []
|
const movieTags = assignments[movie.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
if (ratingValue !== null) {
|
|
||||||
const r = movie.userRating
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte' && r < ratingValue) return false
|
|
||||||
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
|
||||||
if (ratingOperator === 'lte' && r > ratingValue) return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}), [movies, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
})
|
||||||
|
|
||||||
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
||||||
|
|
||||||
@@ -98,7 +71,7 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
const handleDoomScroll = () => {
|
const handleDoomScroll = () => {
|
||||||
// Use filtered movies — respects any active search/tag filters automatically
|
// Use filtered movies — respects any active search/tag filters automatically
|
||||||
@@ -159,9 +132,6 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
ratingValue={ratingValue}
|
|
||||||
ratingOperator={ratingOperator}
|
|
||||||
onRatingChange={handleRatingChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -231,7 +201,6 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
<MovieDetailModal
|
<MovieDetailModal
|
||||||
movie={selected}
|
movie={selected}
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
readOnly={readOnly}
|
|
||||||
onClose={() => setSelectedIndex(null)}
|
onClose={() => setSelectedIndex(null)}
|
||||||
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
||||||
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import type { Tag, TagCategory } from '@/types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
itemKey: string
|
|
||||||
refreshKey?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AssignedTagBadges({ itemKey, refreshKey }: Props) {
|
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
|
||||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true)
|
|
||||||
fetch(`/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: { tags: Tag[]; categories: TagCategory[] }) => {
|
|
||||||
setTags(data.tags ?? [])
|
|
||||||
setCategories(data.categories ?? [])
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [itemKey, refreshKey])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{[60, 80, 50].map((w) => (
|
|
||||||
<div
|
|
||||||
key={w}
|
|
||||||
className="h-5 rounded-full animate-pulse"
|
|
||||||
style={{ width: w, backgroundColor: 'var(--border)' }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.length === 0) return null
|
|
||||||
|
|
||||||
const catMap = new Map(categories.map((c) => [c.id, c.name]))
|
|
||||||
|
|
||||||
// Group by category
|
|
||||||
const grouped = new Map<string | null, Tag[]>()
|
|
||||||
for (const tag of tags) {
|
|
||||||
const key = tag.categoryId ?? null
|
|
||||||
if (!grouped.has(key)) grouped.set(key, [])
|
|
||||||
grouped.get(key)!.push(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{Array.from(grouped.entries()).map(([catId, catTags]) => {
|
|
||||||
const catName = catId ? catMap.get(catId) : null
|
|
||||||
return catTags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag.id}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
{catName && (
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{catName}:</span>
|
|
||||||
)}
|
|
||||||
{tag.name}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import TagSelector from './TagSelector'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
itemKey: string
|
|
||||||
onHide: () => void
|
|
||||||
onClose: () => void
|
|
||||||
onTagsChanged?: () => void
|
|
||||||
externalRefreshKey?: number
|
|
||||||
onAiTag?: () => Promise<void>
|
|
||||||
disabled?: boolean
|
|
||||||
disabledMessage?: string
|
|
||||||
readOnly?: boolean
|
|
||||||
children?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
|
||||||
|
|
||||||
export default function MediaTagPanel({
|
|
||||||
itemKey,
|
|
||||||
onHide,
|
|
||||||
onClose,
|
|
||||||
onTagsChanged,
|
|
||||||
externalRefreshKey = 0,
|
|
||||||
onAiTag,
|
|
||||||
disabled,
|
|
||||||
disabledMessage,
|
|
||||||
readOnly,
|
|
||||||
children,
|
|
||||||
}: Props) {
|
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
|
||||||
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
|
|
||||||
const [userRating, setUserRatingState] = useState<number | null>(null)
|
|
||||||
const [ratingHover, setRatingHover] = useState<number | null>(null)
|
|
||||||
const [savingRating, setSavingRating] = useState(false)
|
|
||||||
|
|
||||||
const fetchRating = useCallback(async () => {
|
|
||||||
if (!itemKey) return
|
|
||||||
const res = await fetch(`/api/ratings?itemKey=${encodeURIComponent(itemKey)}`)
|
|
||||||
if (res.ok) {
|
|
||||||
const { userRating: r } = await res.json()
|
|
||||||
setUserRatingState(r)
|
|
||||||
}
|
|
||||||
}, [itemKey])
|
|
||||||
|
|
||||||
useEffect(() => { fetchRating() }, [fetchRating])
|
|
||||||
|
|
||||||
const setRating = async (star: number) => {
|
|
||||||
const next = userRating === star ? null : star
|
|
||||||
setSavingRating(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ratings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ itemKey, userRating: next }),
|
|
||||||
})
|
|
||||||
if (res.ok) setUserRatingState(next)
|
|
||||||
} finally {
|
|
||||||
setSavingRating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAiTag = async () => {
|
|
||||||
if (!onAiTag) return
|
|
||||||
setAiTagging(true)
|
|
||||||
setAiTagError(null)
|
|
||||||
try {
|
|
||||||
await onAiTag()
|
|
||||||
setInternalRefreshKey((k) => k + 1)
|
|
||||||
onTagsChanged?.()
|
|
||||||
} catch (err) {
|
|
||||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
|
||||||
setTimeout(() => setAiTagError(null), 4000)
|
|
||||||
} finally {
|
|
||||||
setAiTagging(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Panel header — ‹ hide | ✕ close */}
|
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={onHide}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Hide panel"
|
|
||||||
title="Hide panel"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{disabled || !itemKey ? (
|
|
||||||
disabledMessage ? (
|
|
||||||
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{disabledMessage}
|
|
||||||
</p>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Rating section */}
|
|
||||||
<div className="mt-4 mb-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Rating
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => {
|
|
||||||
const filled = (ratingHover ?? userRating ?? 0) >= star
|
|
||||||
return readOnly ? (
|
|
||||||
<span
|
|
||||||
key={star}
|
|
||||||
style={{ fontSize: '1.1rem', color: (userRating ?? 0) >= star ? '#f59e0b' : 'var(--border)' }}
|
|
||||||
aria-label={`${star} star`}
|
|
||||||
>★</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
key={star}
|
|
||||||
onClick={() => setRating(star)}
|
|
||||||
onMouseEnter={() => setRatingHover(star)}
|
|
||||||
disabled={savingRating}
|
|
||||||
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
|
||||||
style={{
|
|
||||||
fontSize: '1.1rem',
|
|
||||||
color: filled ? '#f59e0b' : 'var(--border)',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: '0 1px',
|
|
||||||
cursor: savingRating ? 'wait' : 'pointer',
|
|
||||||
transition: 'color 0.1s',
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>★</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Tags section heading + optional AI button */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
{onAiTag && (
|
|
||||||
<button
|
|
||||||
onClick={handleAiTag}
|
|
||||||
disabled={aiTagging}
|
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
|
||||||
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
}}
|
|
||||||
aria-label="AI Tag"
|
|
||||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
|
||||||
>
|
|
||||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
|
||||||
<TagSelector
|
|
||||||
itemKey={itemKey}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
refreshKey={internalRefreshKey + externalRefreshKey}
|
|
||||||
hideDescription
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,6 @@ interface Props {
|
|||||||
itemKey: string
|
itemKey: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
refreshKey?: number
|
refreshKey?: number
|
||||||
hideDescription?: boolean
|
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AllTags {
|
interface AllTags {
|
||||||
@@ -17,7 +15,7 @@ interface AllTags {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) {
|
export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) {
|
||||||
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
||||||
tags: [],
|
tags: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
@@ -212,39 +210,37 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* AI description */}
|
{/* AI description */}
|
||||||
{!hideDescription && (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
{aiDescription && (
|
||||||
{aiDescription && (
|
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
{aiDescription}
|
||||||
{aiDescription}
|
</p>
|
||||||
</p>
|
)}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateDescription}
|
||||||
|
disabled={generatingDesc}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!generatingDesc) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||||
|
>
|
||||||
|
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
||||||
|
</button>
|
||||||
|
{descError && (
|
||||||
|
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={handleGenerateDescription}
|
|
||||||
disabled={generatingDesc}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!generatingDesc) {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
|
||||||
>
|
|
||||||
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
|
||||||
</button>
|
|
||||||
{descError && (
|
|
||||||
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{/* Assigned tags grouped by category */}
|
{/* Assigned tags grouped by category */}
|
||||||
{assigned.tags.length > 0 && (
|
{assigned.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -278,25 +274,23 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
|
|||||||
style={{ backgroundColor: 'var(--surface-hover)' }}
|
style={{ backgroundColor: 'var(--surface-hover)' }}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
{!readOnly && (
|
<button
|
||||||
<button
|
onClick={() => toggleTag(tag)}
|
||||||
onClick={() => toggleTag(tag)}
|
className="ml-0.5 leading-none transition-colors"
|
||||||
className="ml-0.5 leading-none transition-colors"
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
aria-label={`Remove tag ${tag.name}`}
|
||||||
aria-label={`Remove tag ${tag.name}`}
|
>
|
||||||
>
|
✕
|
||||||
✕
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{ungrouped.map((tag) => (
|
{ungrouped.map((tag) => (
|
||||||
<TagBadge key={tag.id} tag={tag} onRemove={readOnly ? undefined : () => toggleTag(tag)} />
|
<TagBadge key={tag.id} tag={tag} onRemove={() => toggleTag(tag)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -305,7 +299,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tag picker grouped by category */}
|
{/* Tag picker grouped by category */}
|
||||||
{!readOnly && <div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{all.categories.map((category) => {
|
{all.categories.map((category) => {
|
||||||
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
||||||
const search = categorySearches[category.id] ?? ''
|
const search = categorySearches[category.id] ?? ''
|
||||||
@@ -534,7 +528,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ interface Props {
|
|||||||
onTag?: () => void
|
onTag?: () => void
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
onRename?: (newName: string) => Promise<boolean>
|
onRename?: (newName: string) => Promise<boolean>
|
||||||
downloadUrl?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename, downloadUrl }: Props) {
|
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename }: Props) {
|
||||||
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
@@ -80,7 +79,7 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Kebab menu */}
|
{/* Kebab menu */}
|
||||||
{(onDelete || downloadUrl) && (
|
{onDelete && (
|
||||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
|
||||||
@@ -95,19 +94,6 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
|
|||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
{downloadUrl && (
|
|
||||||
<a
|
|
||||||
href={downloadUrl}
|
|
||||||
download
|
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{onRename && (
|
{onRename && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { TvSeries, TvSeason, TvEpisode, RatingOperator } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||||
import { useDebounce } from '@/hooks/useDebounce'
|
|
||||||
|
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
|
||||||
import EpisodeCard from './EpisodeCard'
|
import EpisodeCard from './EpisodeCard'
|
||||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||||
import { isBrowserPlayable } from '@/lib/browser-media'
|
import { isBrowserPlayable } from '@/lib/browser-media'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
readOnly?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewLevel = 'series' | 'seasons' | 'episodes'
|
type ViewLevel = 'series' | 'seasons' | 'episodes'
|
||||||
|
|
||||||
export default function TvView({ libraryId, readOnly }: Props) {
|
export default function TvView({ libraryId }: Props) {
|
||||||
const [view, setView] = useState<ViewLevel>('series')
|
const [view, setView] = useState<ViewLevel>('series')
|
||||||
const [series, setSeries] = useState<TvSeries[]>([])
|
const [series, setSeries] = useState<TvSeries[]>([])
|
||||||
const [seasons, setSeasons] = useState<TvSeason[]>([])
|
const [seasons, setSeasons] = useState<TvSeason[]>([])
|
||||||
@@ -34,15 +30,8 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
|
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
|
||||||
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
|
||||||
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
|
||||||
const debouncedSearch = useDebounce(search, 200)
|
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
|
||||||
)
|
|
||||||
const [selectedSeriesIndex, setSelectedSeriesIndex] = useState<number | null>(null)
|
|
||||||
const [selectedSeasonIndex, setSelectedSeasonIndex] = useState<number | null>(null)
|
|
||||||
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
@@ -59,12 +48,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
|
||||||
const [tagPanelItemKey, setTagPanelItemKey] = useState<string | null>(null)
|
|
||||||
const [tagPanelDisabled, setTagPanelDisabled] = useState(false)
|
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -103,7 +87,6 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
|
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
|
||||||
|
|
||||||
const openSeries = (s: TvSeries) => {
|
const openSeries = (s: TvSeries) => {
|
||||||
setSelectedSeriesIndex(filteredSeries.indexOf(s))
|
|
||||||
setSelectedSeries(s)
|
setSelectedSeries(s)
|
||||||
setView('seasons')
|
setView('seasons')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -113,17 +96,18 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
.then((data: TvSeason[]) => {
|
.then((data: TvSeason[]) => {
|
||||||
setSeasons(data)
|
setSeasons(data)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
// Flat series: a single synthetic season (id='.') means episodes live
|
||||||
|
// directly in the series folder — skip the seasons screen automatically.
|
||||||
|
if (data.length === 1 && data[0].id === '.') {
|
||||||
|
openSeason(data[0])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { setError('Failed to load seasons'); setLoading(false) })
|
.catch(() => { setError('Failed to load seasons'); setLoading(false) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSeason = (season: TvSeason, index?: number) => {
|
const openSeason = (season: TvSeason) => {
|
||||||
setSelectedSeasonIndex(index ?? seasons.indexOf(season))
|
|
||||||
setSelectedSeason(season)
|
setSelectedSeason(season)
|
||||||
setView('episodes')
|
setView('episodes')
|
||||||
if (showTagPanel) {
|
|
||||||
setTagPanelDisabled(true)
|
|
||||||
}
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
fetch(
|
fetch(
|
||||||
@@ -150,24 +134,14 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
setView('series')
|
setView('series')
|
||||||
setSelectedSeries(null)
|
setSelectedSeries(null)
|
||||||
setSelectedSeason(null)
|
setSelectedSeason(null)
|
||||||
setSelectedSeriesIndex(null)
|
|
||||||
setSelectedSeasonIndex(null)
|
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
setShowTagPanel(false)
|
|
||||||
setTagPanelItemKey(null)
|
|
||||||
setTagPanelDisabled(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToSeasons = () => {
|
const goToSeasons = () => {
|
||||||
setView('seasons')
|
setView('seasons')
|
||||||
setSelectedSeason(null)
|
setSelectedSeason(null)
|
||||||
setSelectedSeasonIndex(null)
|
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
if (showTagPanel && selectedSeries?.item_key) {
|
|
||||||
setTagPanelItemKey(selectedSeries.item_key)
|
|
||||||
setTagPanelDisabled(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteSeries = () => {
|
const handleDeleteSeries = () => {
|
||||||
@@ -190,18 +164,11 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
setRefreshingMeta(true)
|
setRefreshingMeta(true)
|
||||||
setWarnRefresh(false)
|
setWarnRefresh(false)
|
||||||
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
|
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
|
||||||
const currentId = selectedSeries.id
|
|
||||||
fetch(
|
fetch(
|
||||||
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`,
|
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
)
|
)
|
||||||
.then(() => fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`))
|
.then(() => fetchSeries())
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: TvSeries[]) => {
|
|
||||||
setSeries(data)
|
|
||||||
const updated = data.find((s) => s.id === currentId)
|
|
||||||
if (updated) setSelectedSeries(updated)
|
|
||||||
})
|
|
||||||
.finally(() => setRefreshingMeta(false))
|
.finally(() => setRefreshingMeta(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,114 +312,29 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape key + body scroll lock when modal is open
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
useEffect(() => {
|
|
||||||
if (view === 'series') return
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key !== 'Escape') return
|
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
|
||||||
if (showTagPanel) { setShowTagPanel(false); return }
|
|
||||||
if (view === 'episodes') {
|
|
||||||
setView('seasons')
|
|
||||||
setSelectedSeason(null)
|
|
||||||
setConfirming(false)
|
|
||||||
if (selectedSeries?.item_key) {
|
|
||||||
setTagPanelItemKey(selectedSeries.item_key)
|
|
||||||
setTagPanelDisabled(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setView('series')
|
|
||||||
setSelectedSeries(null)
|
|
||||||
setSelectedSeason(null)
|
|
||||||
setMenuOpen(false)
|
|
||||||
setConfirming(false)
|
|
||||||
setShowTagPanel(false)
|
|
||||||
setTagPanelItemKey(null)
|
|
||||||
setTagPanelDisabled(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleKey)
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKey)
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
}
|
|
||||||
}, [view, menuOpen, showTagPanel, selectedSeries])
|
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
const filteredSeries = series.filter((s) => {
|
||||||
|
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
|
||||||
if (value === ratingValue && operator === ratingOperator) {
|
|
||||||
setRatingValue(null)
|
|
||||||
} else {
|
|
||||||
setRatingValue(value)
|
|
||||||
setRatingOperator(operator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredSeries = useMemo(() => series.filter((s) => {
|
|
||||||
if (debouncedSearch) {
|
|
||||||
const q = debouncedSearch.toLowerCase()
|
|
||||||
if (![s.title, s.plot, s.aiDescription, s.extractedText, s.extractedTextTranslated]
|
|
||||||
.some((f) => f?.toLowerCase().includes(q))) return false
|
|
||||||
}
|
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const seriesTags = assignments[s.item_key!] ?? []
|
const seriesTags = assignments[s.item_key!] ?? []
|
||||||
const episodeTags = seriesEpisodeTags[s.id] ?? []
|
const episodeTags = seriesEpisodeTags[s.id] ?? []
|
||||||
const allTags = [...new Set([...seriesTags, ...episodeTags])]
|
const allTags = seriesTags.length === 0 ? episodeTags
|
||||||
|
: episodeTags.length === 0 ? seriesTags
|
||||||
|
: [...new Set([...seriesTags, ...episodeTags])]
|
||||||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
if (ratingValue !== null) {
|
|
||||||
const r = s.userRating
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte' && r < ratingValue) return false
|
|
||||||
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
|
||||||
if (ratingOperator === 'lte' && r > ratingValue) return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}), [series, debouncedSearch, selectedTagIds, assignments, seriesEpisodeTags, ratingValue, ratingOperator])
|
})
|
||||||
|
|
||||||
const filteredEpisodes = useMemo(() => episodes.filter((ep) => {
|
const filteredEpisodes = episodes.filter((ep) => {
|
||||||
if (debouncedSearch) {
|
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
const q = debouncedSearch.toLowerCase()
|
|
||||||
if (![ep.title, ep.plot, ep.aiDescription, ep.extractedText, ep.extractedTextTranslated]
|
|
||||||
.some((f) => f?.toLowerCase().includes(q))) return false
|
|
||||||
}
|
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const epTags = assignments[ep.item_key!] ?? []
|
const epTags = assignments[ep.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
if (ratingValue !== null) {
|
|
||||||
const r = ep.userRating
|
|
||||||
if (r === null) return false
|
|
||||||
if (ratingOperator === 'gte' && r < ratingValue) return false
|
|
||||||
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
|
||||||
if (ratingOperator === 'lte' && r > ratingValue) return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}), [episodes, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
})
|
||||||
|
|
||||||
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
|
|
||||||
useEffect(() => {
|
|
||||||
if (view === 'series') return
|
|
||||||
const handleArrowKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex > 0)
|
|
||||||
openSeries(filteredSeries[selectedSeriesIndex - 1])
|
|
||||||
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex > 0)
|
|
||||||
openSeason(seasons[selectedSeasonIndex - 1], selectedSeasonIndex - 1)
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1)
|
|
||||||
openSeries(filteredSeries[selectedSeriesIndex + 1])
|
|
||||||
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1)
|
|
||||||
openSeason(seasons[selectedSeasonIndex + 1], selectedSeasonIndex + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleArrowKey)
|
|
||||||
return () => document.removeEventListener('keydown', handleArrowKey)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [view, selectedSeriesIndex, selectedSeasonIndex, filteredSeries, seasons])
|
|
||||||
|
|
||||||
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
|
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
|
||||||
|
|
||||||
@@ -468,7 +350,6 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
||||||
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||||
context="tv"
|
context="tv"
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -557,9 +438,6 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
ratingValue={ratingValue}
|
|
||||||
ratingOperator={ratingOperator}
|
|
||||||
onRatingChange={handleRatingChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -624,76 +502,9 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{tagPanel && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{tagPanel.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setTagPanel(null)}
|
|
||||||
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4">
|
|
||||||
<TagSelector
|
|
||||||
itemKey={tagPanel.itemKey}
|
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(view === 'seasons' || view === 'episodes') && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 overflow-hidden"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
|
|
||||||
>
|
|
||||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
|
||||||
<div className="flex-1 min-h-0 min-w-0 relative" onClick={goToSeries}>
|
|
||||||
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
|
||||||
<div
|
|
||||||
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{view === 'episodes' && (
|
|
||||||
<div className="flex items-center gap-2 px-5 py-3 flex-shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); goToSeasons() }}
|
|
||||||
className="text-sm transition-colors hover:underline"
|
|
||||||
style={{ color: 'var(--accent)' }}
|
|
||||||
>
|
|
||||||
‹ {selectedSeries?.title}
|
|
||||||
</button>
|
|
||||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>·</span>
|
|
||||||
<span className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{selectedSeason?.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{view === 'seasons' && selectedSeries && (
|
{view === 'seasons' && selectedSeries && (
|
||||||
<div>
|
<div>
|
||||||
{/* Series info header */}
|
{/* Series info header */}
|
||||||
@@ -871,11 +682,6 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
{selectedSeries.plot && (
|
{selectedSeries.plot && (
|
||||||
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
|
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
|
||||||
)}
|
)}
|
||||||
{selectedSeries.item_key && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<AssignedTagBadges itemKey={selectedSeries.item_key} refreshKey={tagRefreshKey} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -950,7 +756,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
{seasons.map((season) => (
|
{seasons.map((season) => (
|
||||||
<button
|
<button
|
||||||
key={season.id}
|
key={season.id}
|
||||||
onClick={() => openSeason(season, seasons.indexOf(season))}
|
onClick={() => openSeason(season)}
|
||||||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -986,7 +792,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'episodes' && selectedSeason && (
|
{view === 'episodes' && selectedSeason && (
|
||||||
<div className="p-4">
|
<div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<EpisodeLoadingGrid />
|
<EpisodeLoadingGrid />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -1002,8 +808,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
key={ep.id}
|
key={ep.id}
|
||||||
episode={ep}
|
episode={ep}
|
||||||
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
||||||
onTag={() => { setTagPanelItemKey(ep.item_key!); setTagPanelDisabled(false); setShowTagPanel(true) }}
|
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
|
||||||
downloadUrl={`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`}
|
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
fetch(
|
fetch(
|
||||||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
|
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
|
||||||
@@ -1033,91 +838,42 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{tagPanel && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{tagPanel.title}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{/* Floating controls — tag + close */}
|
onClick={() => setTagPanel(null)}
|
||||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
<button
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
className={smallBtn}
|
aria-label="Close"
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
>
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
✕
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
</button>
|
||||||
aria-label="Show tags"
|
|
||||||
title="Tags"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={goToSeries}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prev — series in seasons view, season in episodes view */}
|
|
||||||
{(view === 'seasons'
|
|
||||||
? selectedSeriesIndex !== null && selectedSeriesIndex > 0
|
|
||||||
: selectedSeasonIndex !== null && selectedSeasonIndex > 0) && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! - 1])
|
|
||||||
else openSeason(seasons[selectedSeasonIndex! - 1], selectedSeasonIndex! - 1)
|
|
||||||
}}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Previous"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Next — series in seasons view, season in episodes view */}
|
|
||||||
{(view === 'seasons'
|
|
||||||
? selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1
|
|
||||||
: selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1) && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! + 1])
|
|
||||||
else openSeason(seasons[selectedSeasonIndex! + 1], selectedSeasonIndex! + 1)
|
|
||||||
}}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
{/* Right tag panel */}
|
<TagSelector
|
||||||
{showTagPanel && (
|
itemKey={tagPanel.itemKey}
|
||||||
<MediaTagPanel
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
|
||||||
itemKey={tagPanelItemKey ?? ''}
|
|
||||||
onHide={() => setShowTagPanel(false)}
|
|
||||||
onClose={goToSeries}
|
|
||||||
onTagsChanged={() => {
|
|
||||||
setTagRefreshKey((k) => k + 1)
|
|
||||||
setFilterRefreshKey((k) => k + 1)
|
|
||||||
fetchAssignments()
|
|
||||||
fetchSeriesEpisodeTags()
|
|
||||||
}}
|
|
||||||
externalRefreshKey={tagRefreshKey}
|
|
||||||
disabled={tagPanelDisabled}
|
|
||||||
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export function useDebounce<T>(value: T, delayMs: number): T {
|
|
||||||
const [debounced, setDebounced] = useState<T>(value)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setTimeout(() => setDebounced(value), delayMs)
|
|
||||||
return () => clearTimeout(id)
|
|
||||||
}, [value, delayMs])
|
|
||||||
|
|
||||||
return debounced
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,6 @@ interface AiJobRow {
|
|||||||
started_at: number | null
|
started_at: number | null
|
||||||
completed_at: number | null
|
completed_at: number | null
|
||||||
item_title: string | null
|
item_title: string | null
|
||||||
payload: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToJob(row: AiJobRow): AiJob {
|
function rowToJob(row: AiJobRow): AiJob {
|
||||||
@@ -76,7 +75,6 @@ export function enqueueJob(
|
|||||||
jobType: AiJobType,
|
jobType: AiJobType,
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
sourceLanguage?: string,
|
sourceLanguage?: string,
|
||||||
payload?: Record<string, string>,
|
|
||||||
): string {
|
): string {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
||||||
@@ -98,9 +96,9 @@ export function enqueueJob(
|
|||||||
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title, payload)
|
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
|
||||||
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
|
||||||
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
|
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
|
||||||
|
|
||||||
// Wake the processor
|
// Wake the processor
|
||||||
wakeProcessor()
|
wakeProcessor()
|
||||||
@@ -253,14 +251,13 @@ async function processNextJob(): Promise<boolean> {
|
|||||||
|
|
||||||
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
||||||
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
||||||
// Parse job payload (carries per-call overrides, e.g. ocrLanguages for extract jobs)
|
|
||||||
const jobPayload = row.payload ? (JSON.parse(row.payload) as Record<string, string>) : null
|
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
||||||
).run(now, row.id)
|
).run(now, row.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[ai-jobs] Processing job ${row.id}: ${row.job_type} for "${row.item_key}"`)
|
||||||
switch (row.job_type) {
|
switch (row.job_type) {
|
||||||
case 'tag':
|
case 'tag':
|
||||||
await tagSingleItem(row.item_key)
|
await tagSingleItem(row.item_key)
|
||||||
@@ -269,7 +266,358 @@ async function processNextJob(): Promise<boolean> {
|
|||||||
await generateItemDescription(row.item_key)
|
await generateItemDescription(row.item_key)
|
||||||
break
|
break
|
||||||
case 'extract':
|
case 'extract':
|
||||||
await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
|
await extractItemText(row.item_key)
|
||||||
|
break
|
||||||
|
case 'translate':
|
||||||
|
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'completed', completed_at = ? WHERE id = ?"
|
||||||
|
).run(Date.now(), row.id)
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||||
|
const attempt = row.attempt + 1
|
||||||
|
|
||||||
|
if (attempt < row.max_retries) {
|
||||||
|
// Re-queue for retry
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'queued', attempt = ?, error = ?, started_at = NULL WHERE id = ?"
|
||||||
|
).run(attempt, errorMessage, row.id)
|
||||||
|
} else {
|
||||||
|
// Final failure
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'failed', attempt = ?, error = ?, completed_at = ? WHERE id = ?"
|
||||||
|
).run(attempt, errorMessage, Date.now(), row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[ai-jobs] Job ${row.id} (${row.job_type} for "${row.item_key}") failed (attempt ${attempt}/${row.max_retries}):`,
|
||||||
|
errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProcessor(): Promise<void> {
|
||||||
|
if (processorRunning) return
|
||||||
|
processorRunning = true
|
||||||
|
console.log('[ai-jobs] Processor started')
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const hadWork = await processNextJob()
|
||||||
|
if (!hadWork) {
|
||||||
|
// Wait for a wake signal or timeout after 60s (then check again for safety)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
processorWake = resolve
|
||||||
|
setTimeout(() => {
|
||||||
|
processorWake = null
|
||||||
|
resolve()
|
||||||
|
}, 60_000)
|
||||||
|
})
|
||||||
|
processorWake = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ai-jobs] Processor crashed:', err)
|
||||||
|
} finally {
|
||||||
|
processorRunning = false
|
||||||
|
console.log('[ai-jobs] Processor stopped')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the job processor. Called on server startup.
|
||||||
|
* Resets any jobs stuck in 'running' state (from a previous crash) back to 'queued'.
|
||||||
|
*/
|
||||||
|
export function initJobProcessor(): void {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("UPDATE ai_jobs SET status = 'queued', started_at = NULL WHERE status = 'running'")
|
||||||
|
.run()
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`[ai-jobs] Reset ${result.changes} stuck running job(s) to queued`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any queued jobs and start the processor
|
||||||
|
const pending = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM ai_jobs WHERE status = 'queued'")
|
||||||
|
.get() as { count: number }
|
||||||
|
if (pending.count > 0) {
|
||||||
|
runProcessor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { getAiMaxRetries } from './app-settings'
|
||||||
|
import { tagSingleItem, generateItemDescription, extractItemText, translateItemText } from './ai-tagger'
|
||||||
|
|
||||||
|
export type AiJobType = 'tag' | 'describe' | 'extract' | 'translate'
|
||||||
|
export type AiJobStatus = 'queued' | 'running' | 'completed' | 'failed'
|
||||||
|
|
||||||
|
export interface AiJob {
|
||||||
|
id: string
|
||||||
|
itemKey: string
|
||||||
|
libraryId: string
|
||||||
|
jobType: AiJobType
|
||||||
|
status: AiJobStatus
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
maxRetries: number
|
||||||
|
createdAt: number
|
||||||
|
startedAt: number | null
|
||||||
|
completedAt: number | null
|
||||||
|
itemTitle: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiJobRow {
|
||||||
|
id: string
|
||||||
|
item_key: string
|
||||||
|
library_id: string
|
||||||
|
job_type: string
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
max_retries: number
|
||||||
|
created_at: number
|
||||||
|
started_at: number | null
|
||||||
|
completed_at: number | null
|
||||||
|
item_title: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToJob(row: AiJobRow): AiJob {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
itemKey: row.item_key,
|
||||||
|
libraryId: row.library_id,
|
||||||
|
jobType: row.job_type as AiJobType,
|
||||||
|
status: row.status as AiJobStatus,
|
||||||
|
error: row.error,
|
||||||
|
attempt: row.attempt,
|
||||||
|
maxRetries: row.max_retries,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
completedAt: row.completed_at,
|
||||||
|
itemTitle: row.item_title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the title of a media item for display purposes.
|
||||||
|
*/
|
||||||
|
function resolveItemTitle(itemKey: string): string | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT title FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as { title: string | null } | undefined
|
||||||
|
return row?.title ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Enqueue ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue an AI job. Deduplicates: if a queued/running job with the same
|
||||||
|
* item_key + job_type already exists, returns its ID instead.
|
||||||
|
*/
|
||||||
|
export function enqueueJob(
|
||||||
|
itemKey: string,
|
||||||
|
jobType: AiJobType,
|
||||||
|
libraryId: string,
|
||||||
|
sourceLanguage?: string,
|
||||||
|
): string {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// Deduplication: check for existing queued/running job
|
||||||
|
const existing = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM ai_jobs
|
||||||
|
WHERE item_key = ? AND job_type = ? AND status IN ('queued', 'running')`
|
||||||
|
)
|
||||||
|
.get(itemKey, jobType) as { id: string } | undefined
|
||||||
|
if (existing) return existing.id
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const maxRetries = getAiMaxRetries()
|
||||||
|
const title = resolveItemTitle(itemKey)
|
||||||
|
|
||||||
|
// Store sourceLanguage in the error field temporarily for translate jobs
|
||||||
|
// (it's null at creation, so we repurpose it briefly — cleared when the job runs)
|
||||||
|
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
|
||||||
|
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
|
||||||
|
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
|
||||||
|
|
||||||
|
// Wake the processor
|
||||||
|
wakeProcessor()
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue jobs for all media items in a directory (for bulk operations).
|
||||||
|
* Returns the list of job IDs created.
|
||||||
|
*/
|
||||||
|
export function enqueueBulkJobs(
|
||||||
|
libraryId: string,
|
||||||
|
dirPath: string,
|
||||||
|
jobType: AiJobType,
|
||||||
|
itemTypeFilter?: string,
|
||||||
|
extFilter?: Set<string>,
|
||||||
|
): string[] {
|
||||||
|
const db = getDb()
|
||||||
|
const prefix = dirPath
|
||||||
|
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
|
||||||
|
: `${libraryId}:mixed_file:`
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.prepare('SELECT item_key, item_type, file_path FROM media_items WHERE item_key LIKE ? AND item_type = ?')
|
||||||
|
.all(`${prefix}%`, itemTypeFilter ?? 'mixed_file') as Array<{ item_key: string; item_type: string; file_path: string | null }>
|
||||||
|
|
||||||
|
const path = require('path')
|
||||||
|
const jobIds: string[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.file_path) continue
|
||||||
|
if (extFilter) {
|
||||||
|
const ext = path.extname(item.file_path).toLowerCase()
|
||||||
|
if (!extFilter.has(ext)) continue
|
||||||
|
}
|
||||||
|
const jobId = enqueueJob(item.item_key, jobType, libraryId)
|
||||||
|
jobIds.push(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobIds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Query ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getJobQueue(): AiJob[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status IN ('running', 'queued')
|
||||||
|
ORDER BY
|
||||||
|
CASE status WHEN 'running' THEN 0 ELSE 1 END,
|
||||||
|
created_at ASC`
|
||||||
|
)
|
||||||
|
.all() as AiJobRow[]
|
||||||
|
return rows.map(rowToJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJobHistory(limit = 50): AiJob[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status IN ('completed', 'failed')
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(limit) as AiJobRow[]
|
||||||
|
return rows.map(rowToJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(jobId: string): AiJob | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM ai_jobs WHERE id = ?')
|
||||||
|
.get(jobId) as AiJobRow | undefined
|
||||||
|
return row ? rowToJob(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function retryJob(jobId: string): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ai_jobs SET status = 'queued', error = NULL, attempt = 0, started_at = NULL, completed_at = NULL
|
||||||
|
WHERE id = ? AND status = 'failed'`
|
||||||
|
)
|
||||||
|
.run(jobId)
|
||||||
|
if (result.changes > 0) {
|
||||||
|
wakeProcessor()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelJob(jobId: string): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE id = ? AND status = 'queued'")
|
||||||
|
.run(jobId)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAllQueued(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE status = 'queued'")
|
||||||
|
.run()
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearJobHistory(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE status IN ('completed', 'failed')")
|
||||||
|
.run()
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Processor ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let processorRunning = false
|
||||||
|
let processorWake: (() => void) | null = null
|
||||||
|
|
||||||
|
function wakeProcessor(): void {
|
||||||
|
if (processorWake) {
|
||||||
|
processorWake()
|
||||||
|
} else if (!processorRunning) {
|
||||||
|
runProcessor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processNextJob(): Promise<boolean> {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status = 'queued'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get() as AiJobRow | undefined
|
||||||
|
|
||||||
|
if (!row) return false
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
||||||
|
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
||||||
|
).run(now, row.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (row.job_type) {
|
||||||
|
case 'tag':
|
||||||
|
await tagSingleItem(row.item_key)
|
||||||
|
break
|
||||||
|
case 'describe':
|
||||||
|
await generateItemDescription(row.item_key)
|
||||||
|
break
|
||||||
|
case 'extract':
|
||||||
|
await extractItemText(row.item_key)
|
||||||
break
|
break
|
||||||
case 'translate':
|
case 'translate':
|
||||||
await translateItemText(row.item_key, sourceLanguage || undefined)
|
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Library, Tag, TagCategory } from '@/types'
|
|||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
|
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
|
||||||
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
|
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
|
||||||
import { getAiImagePath, getOcrImagePath, getVideoFramePaths } from './thumbnails'
|
import { getAiImagePath, getVideoFramePaths } from './thumbnails'
|
||||||
import { findFile } from './media-utils'
|
import { findFile } from './media-utils'
|
||||||
import { getLibrary, resolveLibraryRoot } from './libraries'
|
import { getLibrary, resolveLibraryRoot } from './libraries'
|
||||||
|
|
||||||
@@ -171,8 +171,7 @@ async function callVisionApi(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
model: string,
|
model: string,
|
||||||
base64Images: string[],
|
base64Images: string[],
|
||||||
systemPrompt: string,
|
systemPrompt: string
|
||||||
maxTokens: number,
|
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
||||||
|
|
||||||
@@ -196,7 +195,7 @@ async function callVisionApi(
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: maxTokens,
|
max_tokens: 8192,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -339,7 +338,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
customInstruction: config.promptTagger || undefined,
|
customInstruction: config.promptTagger || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag)
|
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
|
||||||
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
||||||
|
|
||||||
for (const tagId of validIds) {
|
for (const tagId of validIds) {
|
||||||
@@ -360,8 +359,7 @@ async function callVisionApiText(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
model: string,
|
model: string,
|
||||||
base64Images: string[],
|
base64Images: string[],
|
||||||
systemPrompt: string,
|
systemPrompt: string
|
||||||
maxTokens: number,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
||||||
|
|
||||||
@@ -385,7 +383,7 @@ async function callVisionApiText(
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: maxTokens,
|
max_tokens: 8192,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -412,8 +410,7 @@ async function callChatApiText(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
model: string,
|
model: string,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
userMessage: string,
|
userMessage: string
|
||||||
maxTokens: number,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
||||||
|
|
||||||
@@ -431,7 +428,7 @@ async function callChatApiText(
|
|||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: userMessage },
|
{ role: 'user', content: userMessage },
|
||||||
],
|
],
|
||||||
max_tokens: maxTokens,
|
max_tokens: 8192,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -499,7 +496,7 @@ export async function generateItemDescription(itemKey: string): Promise<string>
|
|||||||
: ''
|
: ''
|
||||||
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
|
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
|
||||||
|
|
||||||
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt, config.maxTokensDescribe)
|
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
|
||||||
|
|
||||||
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
||||||
|
|
||||||
@@ -509,38 +506,36 @@ export async function generateItemDescription(itemKey: string): Promise<string>
|
|||||||
// ─── Text extraction ─────────────────────────────────────────────────────────
|
// ─── Text extraction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run Tesseract OCR on a preprocessed image file.
|
* Extract text (OCR) from an image using the vision model.
|
||||||
* Returns the extracted text and a mean confidence score (0–100).
|
* Only works for images in mixed libraries.
|
||||||
* A confidence of 0 with empty text means no recognisable text was found.
|
* If the extracted text is not in the user's preferred language, auto-translates it.
|
||||||
|
* Returns { extractedText, translatedText }.
|
||||||
*/
|
*/
|
||||||
async function extractWithTesseract(
|
/**
|
||||||
imagePath: string,
|
* Parse a structured extraction response from the AI.
|
||||||
languages: string,
|
* Returns null if the response cannot be parsed as valid JSON with the expected shape.
|
||||||
): Promise<{ text: string; confidence: number }> {
|
*/
|
||||||
const { createWorker } = await import('tesseract.js')
|
function parseStructuredExtraction(raw: string): { text: string; needsTranslation: boolean } | null {
|
||||||
const workerPath = path.join(process.cwd(), 'node_modules/tesseract.js/src/worker-script/node/index.js')
|
const jsonMatch = raw.match(/\{[\s\S]*\}/)
|
||||||
const worker = await createWorker(languages, 1, { workerPath })
|
if (!jsonMatch) return null
|
||||||
try {
|
try {
|
||||||
const { data } = await worker.recognize(imagePath)
|
const parsed = JSON.parse(jsonMatch[0])
|
||||||
return { text: data.text.trim(), confidence: data.confidence }
|
if (typeof parsed.text === 'string' && typeof parsed.needsTranslation === 'boolean') {
|
||||||
} finally {
|
return { text: parsed.text, needsTranslation: parsed.needsTranslation }
|
||||||
await worker.terminate()
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||||
* Extract text (OCR) from an image using the configured OCR mode:
|
|
||||||
* - hybrid: try Tesseract first; fall back to LLM if confidence is below threshold
|
|
||||||
* - tesseract: local Tesseract only, no LLM call
|
|
||||||
* - llm: LLM vision API only (original behaviour)
|
|
||||||
*
|
|
||||||
* Only works for images in mixed libraries.
|
|
||||||
* Translation is not performed automatically — call translateItemText() separately.
|
|
||||||
* Returns { extractedText, translatedText } where translatedText is always null.
|
|
||||||
*/
|
|
||||||
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const config = getEffectiveAiConfig(libraryId)
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
const extractModel = config.modelExtract || config.model
|
||||||
|
if (!config.endpoint || !extractModel) {
|
||||||
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
@@ -567,51 +562,72 @@ export async function extractItemText(itemKey: string, ocrLanguagesOverride?: st
|
|||||||
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
|
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ocrMode: configOcrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
|
|
||||||
const ocrMode = ocrModeOverride ?? configOcrMode
|
|
||||||
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
|
|
||||||
|
|
||||||
// ── Tesseract path ────────────────────────────────────────────────────────
|
|
||||||
if (ocrMode === 'tesseract' || ocrMode === 'hybrid') {
|
|
||||||
const ocrImagePath = await getOcrImagePath(resolvedMedia.path, libraryId)
|
|
||||||
const { text, confidence } = await extractWithTesseract(ocrImagePath, ocrLanguages)
|
|
||||||
|
|
||||||
const useTesseractResult = ocrMode === 'tesseract' || confidence >= ocrConfidenceThreshold
|
|
||||||
if (useTesseractResult) {
|
|
||||||
console.log(`[ocr] tesseract used for ${itemKey} (confidence=${confidence}, mode=${ocrMode})`)
|
|
||||||
if (!text) {
|
|
||||||
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
|
|
||||||
return { extractedText: '', translatedText: null }
|
|
||||||
}
|
|
||||||
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(text, itemKey)
|
|
||||||
return { extractedText: text, translatedText: null }
|
|
||||||
}
|
|
||||||
console.log(`[ocr] tesseract confidence too low (${confidence} < ${ocrConfidenceThreshold}), falling back to LLM for ${itemKey}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LLM vision path ───────────────────────────────────────────────────────
|
|
||||||
const extractModel = config.modelExtract || config.model
|
|
||||||
if (!config.endpoint || !extractModel) {
|
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
||||||
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
|
|
||||||
|
const preferredLanguage = getPreferredLanguage()
|
||||||
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
|
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
|
||||||
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
|
|
||||||
|
|
||||||
console.log(`[ocr] llm used for ${itemKey} (mode=${ocrMode})`)
|
// When a preferred language is configured, ask the AI to also flag whether translation is needed.
|
||||||
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
|
// This avoids a separate translation API call for text already in the target language.
|
||||||
|
let systemPrompt: string
|
||||||
|
if (preferredLanguage) {
|
||||||
|
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction}
|
||||||
|
|
||||||
|
Respond ONLY with a valid JSON object — no markdown, no explanation:
|
||||||
|
{"needsTranslation": boolean, "text": "extracted text"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Set needsTranslation to true if the text is NOT already written in ${preferredLanguage}.
|
||||||
|
- Set needsTranslation to false if the text IS in ${preferredLanguage}, or if there is no text.
|
||||||
|
- If there is no text in the image, use exactly: {"needsTranslation": false, "text": "[NO TEXT]"}`
|
||||||
|
} else {
|
||||||
|
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawResponse = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
|
||||||
|
|
||||||
|
// Parse the response — structured JSON when a preferred language is set, plain text otherwise
|
||||||
|
let extractedText: string
|
||||||
|
let needsTranslation: boolean
|
||||||
|
|
||||||
|
if (preferredLanguage) {
|
||||||
|
const parsed = parseStructuredExtraction(rawResponse)
|
||||||
|
if (parsed) {
|
||||||
|
extractedText = parsed.text
|
||||||
|
needsTranslation = parsed.needsTranslation
|
||||||
|
} else {
|
||||||
|
// Malformed JSON fallback: treat raw response as plain text and attempt translation
|
||||||
|
extractedText = rawResponse
|
||||||
|
needsTranslation = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extractedText = rawResponse
|
||||||
|
needsTranslation = false
|
||||||
|
}
|
||||||
|
|
||||||
if (!extractedText || extractedText === '[NO TEXT]') {
|
if (!extractedText || extractedText === '[NO TEXT]') {
|
||||||
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
|
||||||
return { extractedText: '', translatedText: null }
|
return { extractedText: '', translatedText: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(extractedText, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(extractedText, itemKey)
|
||||||
|
|
||||||
return { extractedText, translatedText: null }
|
// Only translate if the extraction step determined the text is not already in the preferred language
|
||||||
|
let translatedText: string | null = null
|
||||||
|
if (preferredLanguage && needsTranslation) {
|
||||||
|
const translateModel = config.modelTranslate || config.model
|
||||||
|
try {
|
||||||
|
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
|
||||||
|
if (translatedText) {
|
||||||
|
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ai-tagger] Translation failed for "${itemKey}":`, err instanceof Error ? err.message : err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { extractedText, translatedText }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -634,13 +650,15 @@ export async function translateItemText(itemKey: string, sourceLanguage?: string
|
|||||||
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
||||||
}
|
}
|
||||||
if (!row.extracted_text) {
|
if (!row.extracted_text) {
|
||||||
return null
|
throw Object.assign(new Error('No extracted text to translate'), { code: 'NO_TEXT' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredLanguage = getPreferredLanguage()
|
const preferredLanguage = getPreferredLanguage()
|
||||||
if (!preferredLanguage) return null
|
if (!preferredLanguage) {
|
||||||
|
throw Object.assign(new Error('No preferred language configured'), { code: 'NO_LANGUAGE' })
|
||||||
|
}
|
||||||
|
|
||||||
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, sourceLanguage)
|
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, sourceLanguage)
|
||||||
if (translatedText) {
|
if (translatedText) {
|
||||||
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
||||||
}
|
}
|
||||||
@@ -656,14 +674,6 @@ export function updateExtractedText(itemKey: string, text: string): void {
|
|||||||
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the ai_description of an item.
|
|
||||||
*/
|
|
||||||
export function updateAiDescription(itemKey: string, description: string): void {
|
|
||||||
const db = getDb()
|
|
||||||
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate text to a target language using the chat API.
|
* Translate text to a target language using the chat API.
|
||||||
* Returns null if the text is already in the target language.
|
* Returns null if the text is already in the target language.
|
||||||
@@ -674,7 +684,6 @@ async function translateText(
|
|||||||
text: string,
|
text: string,
|
||||||
targetLanguage: string,
|
targetLanguage: string,
|
||||||
customInstruction = '',
|
customInstruction = '',
|
||||||
maxTokens = 8192,
|
|
||||||
sourceLanguage?: string,
|
sourceLanguage?: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
let systemPrompt: string
|
let systemPrompt: string
|
||||||
@@ -684,7 +693,7 @@ async function translateText(
|
|||||||
systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
|
systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await callChatApiText(endpoint, model, systemPrompt, text, maxTokens)
|
const result = await callChatApiText(endpoint, model, systemPrompt, text)
|
||||||
|
|
||||||
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
|
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ const DEFAULT_PROMPT_EXTRACT =
|
|||||||
'Be mindful of different colors of text that may indicate different speakers or emphasis.'
|
'Be mindful of different colors of text that may indicate different speakers or emphasis.'
|
||||||
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
|
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
|
||||||
|
|
||||||
export type OcrMode = 'hybrid' | 'tesseract' | 'llm'
|
|
||||||
|
|
||||||
export interface AiConfig {
|
export interface AiConfig {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
model: string
|
model: string
|
||||||
@@ -60,13 +58,6 @@ export interface AiConfig {
|
|||||||
promptTagger: string
|
promptTagger: string
|
||||||
promptExtract: string
|
promptExtract: string
|
||||||
promptTranslate: string
|
promptTranslate: string
|
||||||
maxTokensTag: number
|
|
||||||
maxTokensDescribe: number
|
|
||||||
maxTokensExtract: number
|
|
||||||
maxTokensTranslate: number
|
|
||||||
ocrMode: OcrMode
|
|
||||||
ocrLanguages: string
|
|
||||||
ocrConfidenceThreshold: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAiConfig(): AiConfig {
|
export function getAiConfig(): AiConfig {
|
||||||
@@ -85,19 +76,9 @@ export function getAiConfig(): AiConfig {
|
|||||||
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
|
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
|
||||||
const promptTranslateRaw = getSetting('ai_prompt_translate')
|
const promptTranslateRaw = getSetting('ai_prompt_translate')
|
||||||
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
|
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
|
||||||
const maxTokensTag = parseInt(getSetting('ai_max_tokens_tag') ?? '8192', 10) || 8192
|
|
||||||
const maxTokensDescribe = parseInt(getSetting('ai_max_tokens_describe') ?? '8192', 10) || 8192
|
|
||||||
const maxTokensExtract = parseInt(getSetting('ai_max_tokens_extract') ?? '8192', 10) || 8192
|
|
||||||
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
|
|
||||||
const rawOcrMode = getSetting('ai_ocr_mode') ?? 'hybrid'
|
|
||||||
const ocrMode: OcrMode = rawOcrMode === 'tesseract' || rawOcrMode === 'llm' ? rawOcrMode : 'hybrid'
|
|
||||||
const ocrLanguages = getSetting('ai_ocr_languages') ?? 'eng'
|
|
||||||
const ocrConfidenceThreshold = parseInt(getSetting('ai_ocr_confidence_threshold') ?? '70', 10) || 70
|
|
||||||
return {
|
return {
|
||||||
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
|
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
|
||||||
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
|
||||||
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,13 +94,6 @@ export function updateAiConfig(
|
|||||||
promptTagger?: string,
|
promptTagger?: string,
|
||||||
promptExtract?: string,
|
promptExtract?: string,
|
||||||
promptTranslate?: string,
|
promptTranslate?: string,
|
||||||
maxTokensTag?: number,
|
|
||||||
maxTokensDescribe?: number,
|
|
||||||
maxTokensExtract?: number,
|
|
||||||
maxTokensTranslate?: number,
|
|
||||||
ocrMode?: OcrMode,
|
|
||||||
ocrLanguages?: string,
|
|
||||||
ocrConfidenceThreshold?: number,
|
|
||||||
): void {
|
): void {
|
||||||
setSetting('ai_endpoint', endpoint)
|
setSetting('ai_endpoint', endpoint)
|
||||||
setSetting('ai_model', model)
|
setSetting('ai_model', model)
|
||||||
@@ -132,13 +106,6 @@ export function updateAiConfig(
|
|||||||
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
|
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
|
||||||
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
|
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
|
||||||
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
|
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
|
||||||
if (maxTokensTag !== undefined) setSetting('ai_max_tokens_tag', String(Math.max(1, Math.floor(maxTokensTag))))
|
|
||||||
if (maxTokensDescribe !== undefined) setSetting('ai_max_tokens_describe', String(Math.max(1, Math.floor(maxTokensDescribe))))
|
|
||||||
if (maxTokensExtract !== undefined) setSetting('ai_max_tokens_extract', String(Math.max(1, Math.floor(maxTokensExtract))))
|
|
||||||
if (maxTokensTranslate !== undefined) setSetting('ai_max_tokens_translate', String(Math.max(1, Math.floor(maxTokensTranslate))))
|
|
||||||
if (ocrMode !== undefined) setSetting('ai_ocr_mode', ocrMode)
|
|
||||||
if (ocrLanguages !== undefined) setSetting('ai_ocr_languages', ocrLanguages.trim() || 'eng')
|
|
||||||
if (ocrConfidenceThreshold !== undefined) setSetting('ai_ocr_confidence_threshold', String(Math.max(0, Math.min(100, Math.floor(ocrConfidenceThreshold)))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPreferredLanguage(): string {
|
export function getPreferredLanguage(): string {
|
||||||
@@ -160,10 +127,6 @@ export interface LibraryAiOverrides {
|
|||||||
promptTagger: string
|
promptTagger: string
|
||||||
promptExtract: string
|
promptExtract: string
|
||||||
promptTranslate: string
|
promptTranslate: string
|
||||||
maxTokensTag: number | null
|
|
||||||
maxTokensDescribe: number | null
|
|
||||||
maxTokensExtract: number | null
|
|
||||||
maxTokensTranslate: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LibraryAiSettingsRow {
|
interface LibraryAiSettingsRow {
|
||||||
@@ -175,10 +138,6 @@ interface LibraryAiSettingsRow {
|
|||||||
prompt_tagger: string | null
|
prompt_tagger: string | null
|
||||||
prompt_extract: string | null
|
prompt_extract: string | null
|
||||||
prompt_translate: string | null
|
prompt_translate: string | null
|
||||||
max_tokens_tag: number | null
|
|
||||||
max_tokens_describe: number | null
|
|
||||||
max_tokens_extract: number | null
|
|
||||||
max_tokens_translate: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
|
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
|
||||||
@@ -195,10 +154,6 @@ export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
|
|||||||
promptTagger: row?.prompt_tagger ?? '',
|
promptTagger: row?.prompt_tagger ?? '',
|
||||||
promptExtract: row?.prompt_extract ?? '',
|
promptExtract: row?.prompt_extract ?? '',
|
||||||
promptTranslate: row?.prompt_translate ?? '',
|
promptTranslate: row?.prompt_translate ?? '',
|
||||||
maxTokensTag: row?.max_tokens_tag ?? null,
|
|
||||||
maxTokensDescribe: row?.max_tokens_describe ?? null,
|
|
||||||
maxTokensExtract: row?.max_tokens_extract ?? null,
|
|
||||||
maxTokensTranslate: row?.max_tokens_translate ?? null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +164,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
|
|||||||
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
|
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
|
||||||
).run(libraryId)
|
).run(libraryId)
|
||||||
|
|
||||||
const stringFields: Record<string, string | undefined> = {
|
const fields: Record<string, string | undefined> = {
|
||||||
model_tagging: overrides.modelTagging,
|
model_tagging: overrides.modelTagging,
|
||||||
model_describe: overrides.modelDescribe,
|
model_describe: overrides.modelDescribe,
|
||||||
model_extract: overrides.modelExtract,
|
model_extract: overrides.modelExtract,
|
||||||
@@ -220,7 +175,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
|
|||||||
prompt_translate: overrides.promptTranslate,
|
prompt_translate: overrides.promptTranslate,
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [col, val] of Object.entries(stringFields)) {
|
for (const [col, val] of Object.entries(fields)) {
|
||||||
if (val !== undefined) {
|
if (val !== undefined) {
|
||||||
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
|
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
|
||||||
val === '' ? null : val,
|
val === '' ? null : val,
|
||||||
@@ -228,22 +183,6 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberFields: Record<string, number | null | undefined> = {
|
|
||||||
max_tokens_tag: overrides.maxTokensTag,
|
|
||||||
max_tokens_describe: overrides.maxTokensDescribe,
|
|
||||||
max_tokens_extract: overrides.maxTokensExtract,
|
|
||||||
max_tokens_translate: overrides.maxTokensTranslate,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [col, val] of Object.entries(numberFields)) {
|
|
||||||
if (val !== undefined) {
|
|
||||||
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
|
|
||||||
val === null ? null : Math.max(1, Math.floor(val)),
|
|
||||||
libraryId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEffectiveAiConfig(libraryId: string): AiConfig {
|
export function getEffectiveAiConfig(libraryId: string): AiConfig {
|
||||||
@@ -261,13 +200,6 @@ export function getEffectiveAiConfig(libraryId: string): AiConfig {
|
|||||||
promptTagger: overrides.promptTagger || global.promptTagger,
|
promptTagger: overrides.promptTagger || global.promptTagger,
|
||||||
promptExtract: overrides.promptExtract || global.promptExtract,
|
promptExtract: overrides.promptExtract || global.promptExtract,
|
||||||
promptTranslate: overrides.promptTranslate || global.promptTranslate,
|
promptTranslate: overrides.promptTranslate || global.promptTranslate,
|
||||||
maxTokensTag: overrides.maxTokensTag ?? global.maxTokensTag,
|
|
||||||
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
|
|
||||||
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
|
|
||||||
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
|
|
||||||
ocrMode: global.ocrMode,
|
|
||||||
ocrLanguages: global.ocrLanguages,
|
|
||||||
ocrConfidenceThreshold: global.ocrConfidenceThreshold,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth guard result type
|
// Auth guard result type
|
||||||
type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' }
|
type AuthSuccess = { session: IronSession<SessionData> }
|
||||||
type AuthResult = AuthSuccess | NextResponse
|
type AuthResult = AuthSuccess | NextResponse
|
||||||
|
|
||||||
// Read-only session from an API route request (throwaway response)
|
// Read-only session from an API route request (throwaway response)
|
||||||
@@ -100,22 +100,13 @@ export async function requireLibraryAccess(req: NextRequest, libraryId: string):
|
|||||||
if (!session.userId) {
|
if (!session.userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
if (session.role === 'admin') return { session, accessLevel: 'admin' }
|
if (session.role === 'admin') return { session }
|
||||||
|
|
||||||
// Lazy import to avoid pulling DB into edge contexts
|
// Lazy import to avoid pulling DB into edge contexts
|
||||||
const { getLibraryAccessLevel } = await import('./users')
|
const { getPermittedLibraryIds } = await import('./users')
|
||||||
const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
|
const permitted = getPermittedLibraryIds(session.userId)
|
||||||
if (!accessLevel) {
|
if (!permitted.includes(libraryId)) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
return { session, accessLevel }
|
return { session }
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
|
|
||||||
const result = await requireLibraryAccess(req, libraryId)
|
|
||||||
if (result instanceof NextResponse) return result
|
|
||||||
if (result.accessLevel === 'read') {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import AdmZip from 'adm-zip'
|
|
||||||
import { XMLParser } from 'fast-xml-parser'
|
|
||||||
import type { ComicInfoData } from '@/types'
|
|
||||||
import { findZipEntry, extractZipEntry } from './zip-utils'
|
|
||||||
|
|
||||||
const parser = new XMLParser()
|
|
||||||
|
|
||||||
function toNumber(val: unknown): number | null {
|
|
||||||
if (val === undefined || val === null || val === '') return null
|
|
||||||
const n = Number(val)
|
|
||||||
return isNaN(n) ? null : n
|
|
||||||
}
|
|
||||||
|
|
||||||
function toString(val: unknown): string | null {
|
|
||||||
if (val === undefined || val === null || val === '') return null
|
|
||||||
return String(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse ComicInfo.xml from inside a CBZ archive.
|
|
||||||
* Returns null if the archive doesn't contain ComicInfo.xml or parsing fails.
|
|
||||||
*/
|
|
||||||
export function parseComicInfo(absoluteCbzPath: string): ComicInfoData | null {
|
|
||||||
let zip: AdmZip
|
|
||||||
try {
|
|
||||||
zip = new AdmZip(absoluteCbzPath)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find ComicInfo.xml (case-insensitive)
|
|
||||||
const entry = zip.getEntries().find(
|
|
||||||
(e) => !e.isDirectory && e.entryName.toLowerCase() === 'comicinfo.xml'
|
|
||||||
)
|
|
||||||
if (!entry) return null
|
|
||||||
|
|
||||||
let xml: string
|
|
||||||
try {
|
|
||||||
xml = entry.getData().toString('utf-8')
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let doc: Record<string, unknown>
|
|
||||||
try {
|
|
||||||
doc = parser.parse(xml) as Record<string, unknown>
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// The root element can be ComicInfo or ComicInfoXml (varies by source)
|
|
||||||
const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record<string, unknown> | undefined
|
|
||||||
if (!info) return null
|
|
||||||
|
|
||||||
// Parse tags: comma-separated string
|
|
||||||
const rawTags = toString(info.Tags)
|
|
||||||
const tags: string[] = rawTags
|
|
||||||
? rawTags.split(',').map((t) => t.trim()).filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: toString(info.Title),
|
|
||||||
year: toNumber(info.Year),
|
|
||||||
month: toNumber(info.Month),
|
|
||||||
day: toNumber(info.Day),
|
|
||||||
writer: toString(info.Writer),
|
|
||||||
translator: toString(info.Translator),
|
|
||||||
publisher: toString(info.Publisher),
|
|
||||||
genre: toString(info.Genre),
|
|
||||||
tags,
|
|
||||||
web: toString(info.Web),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async version of parseComicInfo — reads only the ComicInfo.xml entry from the
|
|
||||||
* archive without loading the entire CBZ into memory. This is significantly faster
|
|
||||||
* for large libraries since it reads only the ZIP's central directory + the XML entry.
|
|
||||||
*/
|
|
||||||
export async function parseComicInfoAsync(absoluteCbzPath: string): Promise<ComicInfoData | null> {
|
|
||||||
try {
|
|
||||||
const entry = await findZipEntry(absoluteCbzPath, 'comicinfo.xml')
|
|
||||||
if (!entry) return null
|
|
||||||
const buf = await extractZipEntry(absoluteCbzPath, entry)
|
|
||||||
if (!buf) return null
|
|
||||||
return parseXml(buf.toString('utf-8'))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseXml(xml: string): ComicInfoData | null {
|
|
||||||
let doc: Record<string, unknown>
|
|
||||||
try {
|
|
||||||
doc = parser.parse(xml) as Record<string, unknown>
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record<string, unknown> | undefined
|
|
||||||
if (!info) return null
|
|
||||||
|
|
||||||
const rawTags = toString(info.Tags)
|
|
||||||
const tags: string[] = rawTags
|
|
||||||
? rawTags.split(',').map((t) => t.trim()).filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: toString(info.Title),
|
|
||||||
year: toNumber(info.Year),
|
|
||||||
month: toNumber(info.Month),
|
|
||||||
day: toNumber(info.Day),
|
|
||||||
writer: toString(info.Writer),
|
|
||||||
translator: toString(info.Translator),
|
|
||||||
publisher: toString(info.Publisher),
|
|
||||||
genre: toString(info.Genre),
|
|
||||||
tags,
|
|
||||||
web: toString(info.Web),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import path from 'path'
|
|
||||||
import crypto from 'crypto'
|
|
||||||
import type { Library, ImportedTag, TagMapping } from '@/types'
|
|
||||||
import { getDb } from './db'
|
|
||||||
import { resolveLibraryRoot } from './libraries'
|
|
||||||
import { parseComicInfoAsync } from './comic-info'
|
|
||||||
import { mapConcurrent } from './zip-utils'
|
|
||||||
|
|
||||||
// ─── Metadata Import ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import ComicInfo.xml metadata for all comic_issue items in a library.
|
|
||||||
* - Populates media_items fields (title, year, genres, metadata JSON).
|
|
||||||
* - For each tag: if a mapping exists, assigns the real tag; otherwise creates
|
|
||||||
* an imported tag entry.
|
|
||||||
*/
|
|
||||||
export async function importComicMetadata(library: Library): Promise<void> {
|
|
||||||
const db = getDb()
|
|
||||||
const libraryRoot = resolveLibraryRoot(library)
|
|
||||||
|
|
||||||
// Only process issues that have not had ComicInfo.xml imported yet.
|
|
||||||
// Issues restored from a previous scan will already have year/genres set.
|
|
||||||
const issues = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, file_path, metadata FROM media_items
|
|
||||||
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL
|
|
||||||
AND year IS NULL AND genres IS NULL`
|
|
||||||
)
|
|
||||||
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
|
|
||||||
|
|
||||||
if (issues.length === 0) return
|
|
||||||
|
|
||||||
// Load existing mappings for this library
|
|
||||||
const mappingRows = db
|
|
||||||
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
|
||||||
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
|
|
||||||
const mappings = new Map(mappingRows.map((r) => [r.imported_tag_name, r.tag_id]))
|
|
||||||
|
|
||||||
const updateItem = db.prepare(`
|
|
||||||
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
|
||||||
WHERE item_key = @item_key
|
|
||||||
`)
|
|
||||||
const addMediaTag = db.prepare(
|
|
||||||
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
|
|
||||||
)
|
|
||||||
const upsertImportedTag = db.prepare(`
|
|
||||||
INSERT INTO imported_tags (id, library_id, name) VALUES (@id, @library_id, @name)
|
|
||||||
ON CONFLICT(library_id, name) DO UPDATE SET name = excluded.name
|
|
||||||
RETURNING id
|
|
||||||
`)
|
|
||||||
const addItemImportedTag = db.prepare(
|
|
||||||
'INSERT OR IGNORE INTO item_imported_tags (item_key, imported_tag_id) VALUES (?, ?)'
|
|
||||||
)
|
|
||||||
|
|
||||||
let importedCount = 0
|
|
||||||
|
|
||||||
// Process in batches: async file reads (10 concurrent) followed by batch DB writes,
|
|
||||||
// with an event-loop yield between batches to keep the app responsive.
|
|
||||||
const BATCH_SIZE = 50
|
|
||||||
for (let i = 0; i < issues.length; i += BATCH_SIZE) {
|
|
||||||
const batch = issues.slice(i, i + BATCH_SIZE)
|
|
||||||
|
|
||||||
// Async: read ComicInfo.xml from each archive concurrently (10 at a time).
|
|
||||||
// Uses async ZIP central-directory reader — no full-file reads.
|
|
||||||
const infos = await mapConcurrent(batch, 10, (issue) =>
|
|
||||||
parseComicInfoAsync(path.join(libraryRoot, issue.file_path))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sync: write this batch to the DB in one transaction.
|
|
||||||
db.transaction(() => {
|
|
||||||
for (let j = 0; j < batch.length; j++) {
|
|
||||||
const issue = batch[j]
|
|
||||||
const info = infos[j]
|
|
||||||
if (!info) continue
|
|
||||||
|
|
||||||
const existingMeta = issue.metadata ? JSON.parse(issue.metadata) : {}
|
|
||||||
const mergedMeta = {
|
|
||||||
...existingMeta,
|
|
||||||
writer: info.writer,
|
|
||||||
publisher: info.publisher,
|
|
||||||
translator: info.translator,
|
|
||||||
web: info.web,
|
|
||||||
month: info.month,
|
|
||||||
day: info.day,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItem.run({
|
|
||||||
item_key: issue.item_key,
|
|
||||||
title: info.title ?? existingMeta.title ?? null,
|
|
||||||
year: info.year,
|
|
||||||
genres: info.genre,
|
|
||||||
metadata: JSON.stringify(mergedMeta),
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const tagName of info.tags) {
|
|
||||||
const mappedTagId = mappings.get(tagName)
|
|
||||||
if (mappedTagId) {
|
|
||||||
addMediaTag.run(issue.item_key, mappedTagId)
|
|
||||||
} else {
|
|
||||||
const importedTagId = crypto.randomUUID()
|
|
||||||
const row = upsertImportedTag.get({
|
|
||||||
id: importedTagId,
|
|
||||||
library_id: library.id,
|
|
||||||
name: tagName,
|
|
||||||
}) as { id: string }
|
|
||||||
addItemImportedTag.run(issue.item_key, row.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
importedCount++
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
await new Promise<void>((r) => setImmediate(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[comic-metadata] Imported metadata for ${importedCount}/${issues.length} issues in "${library.name}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Imported Tags ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function getImportedTagsForLibrary(libraryId: string): ImportedTag[] {
|
|
||||||
const db = getDb()
|
|
||||||
return db
|
|
||||||
.prepare(
|
|
||||||
`SELECT it.id, it.library_id as libraryId, it.name,
|
|
||||||
COUNT(iit.item_key) as itemCount
|
|
||||||
FROM imported_tags it
|
|
||||||
LEFT JOIN item_imported_tags iit ON iit.imported_tag_id = it.id
|
|
||||||
WHERE it.library_id = ?
|
|
||||||
GROUP BY it.id
|
|
||||||
ORDER BY it.name`
|
|
||||||
)
|
|
||||||
.all(libraryId) as ImportedTag[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tag Mappings ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function getTagMappingsForLibrary(libraryId: string): TagMapping[] {
|
|
||||||
const db = getDb()
|
|
||||||
return db
|
|
||||||
.prepare(
|
|
||||||
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
|
|
||||||
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
|
|
||||||
FROM tag_mappings tm
|
|
||||||
JOIN tags t ON t.id = tm.tag_id
|
|
||||||
JOIN tag_categories tc ON tc.id = t.category_id
|
|
||||||
WHERE tm.library_id = ?
|
|
||||||
ORDER BY tm.imported_tag_name`
|
|
||||||
)
|
|
||||||
.all(libraryId) as TagMapping[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a tag mapping and apply it: assign the real tag to all items that
|
|
||||||
* currently have the imported tag, then remove the imported tag entries.
|
|
||||||
*/
|
|
||||||
export function createTagMapping(libraryId: string, importedTagName: string, tagId: string): TagMapping {
|
|
||||||
const db = getDb()
|
|
||||||
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
|
|
||||||
return db.transaction(() => {
|
|
||||||
// Persist the mapping for future scans
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO tag_mappings (id, library_id, imported_tag_name, tag_id)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(library_id, imported_tag_name) DO UPDATE SET tag_id = excluded.tag_id
|
|
||||||
`).run(id, libraryId, importedTagName, tagId)
|
|
||||||
|
|
||||||
// Find all items that currently have this imported tag
|
|
||||||
const importedTag = db
|
|
||||||
.prepare('SELECT id FROM imported_tags WHERE library_id = ? AND name = ?')
|
|
||||||
.get(libraryId, importedTagName) as { id: string } | undefined
|
|
||||||
|
|
||||||
if (importedTag) {
|
|
||||||
const itemKeys = db
|
|
||||||
.prepare('SELECT item_key FROM item_imported_tags WHERE imported_tag_id = ?')
|
|
||||||
.all(importedTag.id) as { item_key: string }[]
|
|
||||||
|
|
||||||
// Assign the real tag to all affected items
|
|
||||||
const addMediaTag = db.prepare(
|
|
||||||
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
|
|
||||||
)
|
|
||||||
for (const { item_key } of itemKeys) {
|
|
||||||
addMediaTag.run(item_key, tagId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the imported tag (cascades to item_imported_tags)
|
|
||||||
db.prepare('DELETE FROM imported_tags WHERE id = ?').run(importedTag.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the created mapping with joined names
|
|
||||||
const mapping = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
|
|
||||||
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
|
|
||||||
FROM tag_mappings tm
|
|
||||||
JOIN tags t ON t.id = tm.tag_id
|
|
||||||
JOIN tag_categories tc ON tc.id = t.category_id
|
|
||||||
WHERE tm.library_id = ? AND tm.imported_tag_name = ?`
|
|
||||||
)
|
|
||||||
.get(libraryId, importedTagName) as TagMapping
|
|
||||||
|
|
||||||
return mapping
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteTagMapping(id: string): void {
|
|
||||||
const db = getDb()
|
|
||||||
const result = db.prepare('DELETE FROM tag_mappings WHERE id = ?').run(id)
|
|
||||||
if (result.changes === 0) throw new Error('Mapping not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a media item already has metadata populated.
|
|
||||||
* Returns true if ANY of: title, year, plot, or genres are populated.
|
|
||||||
*/
|
|
||||||
function hasMetadata(item: {
|
|
||||||
title: string | null
|
|
||||||
year: number | null
|
|
||||||
plot: string | null
|
|
||||||
genres: string | null
|
|
||||||
}): boolean {
|
|
||||||
if (item.title) return true
|
|
||||||
if (item.year) return true
|
|
||||||
if (item.plot) return true
|
|
||||||
if (item.genres) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import AdmZip from 'adm-zip'
|
|
||||||
import type { ComicIssue, ComicSeries } from '@/types'
|
|
||||||
import { getDb } from './db'
|
|
||||||
import { HIDDEN_FILES, thumbnailApiUrl } from './media-utils'
|
|
||||||
import { countZipImages, mapConcurrent } from './zip-utils'
|
|
||||||
import fsPromises from 'fs/promises'
|
|
||||||
|
|
||||||
const CBZ_EXTENSIONS = new Set(['.cbz'])
|
|
||||||
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
|
||||||
|
|
||||||
function isCbzFile(name: string): boolean {
|
|
||||||
return CBZ_EXTENSIONS.has(path.extname(name).toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function naturalCompare(a: string, b: string): number {
|
|
||||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseIssueNumber(filename: string): number | null {
|
|
||||||
const base = path.basename(filename, path.extname(filename))
|
|
||||||
const matches = base.match(/\d+/g)
|
|
||||||
if (!matches) return null
|
|
||||||
return parseInt(matches[matches.length - 1], 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScannedComicSeries extends ComicSeries {
|
|
||||||
issues: ComicIssue[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRASH_DIR = '.trash'
|
|
||||||
|
|
||||||
async function moveToTrash(absPath: string, libraryRoot: string): Promise<void> {
|
|
||||||
const trashDir = path.join(libraryRoot, TRASH_DIR)
|
|
||||||
await fsPromises.mkdir(trashDir, { recursive: true })
|
|
||||||
const filename = path.basename(absPath)
|
|
||||||
let dest = path.join(trashDir, filename)
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
const ext = path.extname(filename)
|
|
||||||
const base = path.basename(filename, ext)
|
|
||||||
dest = path.join(trashDir, `${base}_${Date.now()}${ext}`)
|
|
||||||
}
|
|
||||||
await fsPromises.rename(absPath, dest).catch(async (err: NodeJS.ErrnoException) => {
|
|
||||||
if (err.code === 'EXDEV') {
|
|
||||||
// Source and destination are on different filesystems — copy then delete.
|
|
||||||
await fsPromises.copyFile(absPath, dest)
|
|
||||||
await fsPromises.unlink(absPath)
|
|
||||||
} else {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
console.log(`[scanner] Moved corrupt archive to trash: ${path.relative(libraryRoot, absPath)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollectedCbz {
|
|
||||||
absPath: string
|
|
||||||
filename: string
|
|
||||||
relPath: string
|
|
||||||
isStandalone: boolean
|
|
||||||
seriesDirName: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function scanComicsLibrary(
|
|
||||||
libraryRoot: string,
|
|
||||||
libraryId: string
|
|
||||||
): Promise<(ComicIssue | ScannedComicSeries)[]> {
|
|
||||||
let topEntries: fs.Dirent[]
|
|
||||||
try {
|
|
||||||
topEntries = fs.readdirSync(libraryRoot, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1: Collect all CBZ paths via fast directory listing (no archive opens).
|
|
||||||
const collected: CollectedCbz[] = []
|
|
||||||
|
|
||||||
for (const entry of topEntries) {
|
|
||||||
if (HIDDEN_FILES.test(entry.name)) continue
|
|
||||||
|
|
||||||
if (entry.isFile() && isCbzFile(entry.name)) {
|
|
||||||
collected.push({
|
|
||||||
absPath: path.join(libraryRoot, entry.name),
|
|
||||||
filename: entry.name,
|
|
||||||
relPath: entry.name,
|
|
||||||
isStandalone: true,
|
|
||||||
seriesDirName: null,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const dirAbsPath = path.join(libraryRoot, entry.name)
|
|
||||||
let subEntries: fs.Dirent[]
|
|
||||||
try {
|
|
||||||
subEntries = fs.readdirSync(dirAbsPath, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const cbzFiles = subEntries
|
|
||||||
.filter((e) => e.isFile() && isCbzFile(e.name) && !HIDDEN_FILES.test(e.name))
|
|
||||||
.sort((a, b) => naturalCompare(a.name, b.name))
|
|
||||||
|
|
||||||
if (cbzFiles.length === 0) continue
|
|
||||||
|
|
||||||
for (const f of cbzFiles) {
|
|
||||||
collected.push({
|
|
||||||
absPath: path.join(dirAbsPath, f.name),
|
|
||||||
filename: f.name,
|
|
||||||
relPath: path.join(entry.name, f.name),
|
|
||||||
isStandalone: false,
|
|
||||||
seriesDirName: entry.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Count pages for all CBZ files concurrently (10 at a time) by reading
|
|
||||||
// only each archive's central directory — no full-file reads.
|
|
||||||
const scanResults = await mapConcurrent(collected, 10, (c) =>
|
|
||||||
countZipImages(c.absPath, CBZ_IMAGE_EXTENSIONS)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Move corrupt archives to the library's .trash folder and exclude them from indexing.
|
|
||||||
const movePromises: Promise<void>[] = []
|
|
||||||
const valid: Array<{ cbz: CollectedCbz; pageCount: number }> = []
|
|
||||||
for (let i = 0; i < collected.length; i++) {
|
|
||||||
const result = scanResults[i]
|
|
||||||
if (!result.valid) {
|
|
||||||
movePromises.push(
|
|
||||||
moveToTrash(collected[i].absPath, libraryRoot).catch((err) =>
|
|
||||||
console.warn(`[scanner] Could not move corrupt archive to trash: ${collected[i].absPath}`, err)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
valid.push({ cbz: collected[i], pageCount: result.pageCount })
|
|
||||||
}
|
|
||||||
if (movePromises.length > 0) await Promise.all(movePromises)
|
|
||||||
|
|
||||||
// Phase 3: Build the result array from valid files only.
|
|
||||||
const seriesMap = new Map<string, ScannedComicSeries>()
|
|
||||||
const standaloneIssues: ComicIssue[] = []
|
|
||||||
|
|
||||||
for (const { cbz: c, pageCount } of valid) {
|
|
||||||
const coverUrl = thumbnailApiUrl(libraryId, c.relPath)
|
|
||||||
const issue: ComicIssue = {
|
|
||||||
id: encodeURIComponent(c.relPath),
|
|
||||||
title: path.basename(c.filename, path.extname(c.filename)),
|
|
||||||
issueNumber: parseIssueNumber(c.filename),
|
|
||||||
pageCount,
|
|
||||||
coverUrl,
|
|
||||||
filePath: c.relPath,
|
|
||||||
isStandalone: c.isStandalone,
|
|
||||||
userRating: null,
|
|
||||||
aiDescription: null,
|
|
||||||
extractedText: null,
|
|
||||||
extractedTextTranslated: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.isStandalone) {
|
|
||||||
standaloneIssues.push(issue)
|
|
||||||
} else {
|
|
||||||
const key = c.seriesDirName!
|
|
||||||
if (!seriesMap.has(key)) {
|
|
||||||
seriesMap.set(key, {
|
|
||||||
id: encodeURIComponent(key),
|
|
||||||
title: key,
|
|
||||||
coverUrl, // first issue (sorted) becomes the series cover
|
|
||||||
issueCount: 0,
|
|
||||||
issues: [],
|
|
||||||
userRating: null,
|
|
||||||
aiDescription: null,
|
|
||||||
extractedText: null,
|
|
||||||
extractedTextTranslated: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const series = seriesMap.get(key)!
|
|
||||||
series.issues.push(issue)
|
|
||||||
series.issueCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: (ComicIssue | ScannedComicSeries)[] = [
|
|
||||||
...Array.from(seriesMap.values()),
|
|
||||||
...standaloneIssues,
|
|
||||||
]
|
|
||||||
return results.sort((a, b) => naturalCompare(a.title, b.title))
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeLike(s: string): string {
|
|
||||||
return `%${s.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
// comicsFromDb returns series + standalone issues for the top-level grid, paginated.
|
|
||||||
// Series issues are retrieved separately via comicIssuesFromDb.
|
|
||||||
export function comicsFromDb(
|
|
||||||
libraryId: string,
|
|
||||||
opts: { page: number; pageSize: number; search?: string }
|
|
||||||
): { items: (ComicIssue | ComicSeries)[]; total: number } {
|
|
||||||
const db = getDb()
|
|
||||||
const offset = (opts.page - 1) * opts.pageSize
|
|
||||||
|
|
||||||
type DbRow = {
|
|
||||||
item_key: string
|
|
||||||
item_type: string
|
|
||||||
parent_key: string | null
|
|
||||||
title: string | null
|
|
||||||
metadata: string | null
|
|
||||||
file_path: string | null
|
|
||||||
user_rating: number | null
|
|
||||||
ai_description: string | null
|
|
||||||
extracted_text: string | null
|
|
||||||
extracted_text_translated: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseWhere = `
|
|
||||||
WHERE library_id = ?
|
|
||||||
AND (item_type = 'comic_series' OR (item_type = 'comic_issue' AND parent_key IS NULL))
|
|
||||||
`
|
|
||||||
|
|
||||||
const total: number = opts.search
|
|
||||||
? (db
|
|
||||||
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'`)
|
|
||||||
.get(libraryId, escapeLike(opts.search)) as { cnt: number }).cnt
|
|
||||||
: (db
|
|
||||||
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
|
|
||||||
.get(libraryId) as { cnt: number }).cnt
|
|
||||||
|
|
||||||
const cols = `item_key, item_type, parent_key, title, metadata, file_path,
|
|
||||||
user_rating, ai_description, extracted_text, extracted_text_translated`
|
|
||||||
|
|
||||||
const rows: DbRow[] = opts.search
|
|
||||||
? db
|
|
||||||
.prepare(
|
|
||||||
`SELECT ${cols}
|
|
||||||
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
|
|
||||||
ORDER BY title LIMIT ? OFFSET ?`
|
|
||||||
)
|
|
||||||
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
|
|
||||||
: db
|
|
||||||
.prepare(
|
|
||||||
`SELECT ${cols}
|
|
||||||
FROM media_items ${baseWhere}
|
|
||||||
ORDER BY title LIMIT ? OFFSET ?`
|
|
||||||
)
|
|
||||||
.all(libraryId, opts.pageSize, offset) as DbRow[]
|
|
||||||
|
|
||||||
const items: (ComicIssue | ComicSeries)[] = []
|
|
||||||
for (const row of rows) {
|
|
||||||
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
|
||||||
if (row.item_type === 'comic_series') {
|
|
||||||
const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key
|
|
||||||
items.push({
|
|
||||||
id: idPart,
|
|
||||||
item_key: row.item_key,
|
|
||||||
title: row.title ?? decodeURIComponent(idPart),
|
|
||||||
coverUrl: meta.coverUrl ?? null,
|
|
||||||
issueCount: meta.issueCount ?? 0,
|
|
||||||
userRating: row.user_rating ?? null,
|
|
||||||
aiDescription: row.ai_description ?? null,
|
|
||||||
extractedText: row.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
||||||
} as ComicSeries)
|
|
||||||
} else {
|
|
||||||
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
|
||||||
items.push({
|
|
||||||
id: idPart,
|
|
||||||
item_key: row.item_key,
|
|
||||||
title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart),
|
|
||||||
issueNumber: meta.issueNumber ?? null,
|
|
||||||
pageCount: meta.pageCount ?? 0,
|
|
||||||
coverUrl: meta.coverUrl ?? null,
|
|
||||||
filePath: row.file_path ?? '',
|
|
||||||
isStandalone: meta.isStandalone ?? true,
|
|
||||||
userRating: row.user_rating ?? null,
|
|
||||||
aiDescription: row.ai_description ?? null,
|
|
||||||
extractedText: row.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
||||||
} as ComicIssue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, total }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIssue[] {
|
|
||||||
const db = getDb()
|
|
||||||
const seriesKey = `${libraryId}:comic_series:${seriesId}`
|
|
||||||
|
|
||||||
type DbRow = {
|
|
||||||
item_key: string
|
|
||||||
title: string | null
|
|
||||||
metadata: string | null
|
|
||||||
file_path: string | null
|
|
||||||
user_rating: number | null
|
|
||||||
ai_description: string | null
|
|
||||||
extracted_text: string | null
|
|
||||||
extracted_text_translated: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, title, metadata, file_path,
|
|
||||||
user_rating, ai_description, extracted_text, extracted_text_translated
|
|
||||||
FROM media_items
|
|
||||||
WHERE parent_key = ? AND item_type = 'comic_issue'`
|
|
||||||
)
|
|
||||||
.all(seriesKey) as DbRow[]
|
|
||||||
|
|
||||||
const issues: ComicIssue[] = rows.map((row) => {
|
|
||||||
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
|
||||||
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
|
||||||
return {
|
|
||||||
id: idPart,
|
|
||||||
item_key: row.item_key,
|
|
||||||
title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart),
|
|
||||||
issueNumber: meta.issueNumber ?? null,
|
|
||||||
pageCount: meta.pageCount ?? 0,
|
|
||||||
coverUrl: meta.coverUrl ?? null,
|
|
||||||
filePath: row.file_path ?? '',
|
|
||||||
isStandalone: false,
|
|
||||||
userRating: row.user_rating ?? null,
|
|
||||||
aiDescription: row.ai_description ?? null,
|
|
||||||
extractedText: row.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return issues.sort((a, b) => {
|
|
||||||
if (a.issueNumber !== null && b.issueNumber !== null) return a.issueNumber - b.issueNumber
|
|
||||||
if (a.issueNumber !== null) return -1
|
|
||||||
if (b.issueNumber !== null) return 1
|
|
||||||
return naturalCompare(a.title, b.title)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getComicPages(absoluteCbzPath: string): string[] {
|
|
||||||
try {
|
|
||||||
const zip = new AdmZip(absoluteCbzPath)
|
|
||||||
return zip
|
|
||||||
.getEntries()
|
|
||||||
.filter(
|
|
||||||
(e) =>
|
|
||||||
!e.isDirectory &&
|
|
||||||
CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase())
|
|
||||||
)
|
|
||||||
.sort((a, b) => naturalCompare(a.entryName, b.entryName))
|
|
||||||
.map((e) => e.entryName)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getComicPageBuffer(absoluteCbzPath: string, pageIndex: number): { buffer: Buffer; ext: string } | null {
|
|
||||||
try {
|
|
||||||
const zip = new AdmZip(absoluteCbzPath)
|
|
||||||
const entries = zip
|
|
||||||
.getEntries()
|
|
||||||
.filter(
|
|
||||||
(e) =>
|
|
||||||
!e.isDirectory &&
|
|
||||||
CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase())
|
|
||||||
)
|
|
||||||
.sort((a, b) => naturalCompare(a.entryName, b.entryName))
|
|
||||||
|
|
||||||
if (pageIndex < 0 || pageIndex >= entries.length) return null
|
|
||||||
|
|
||||||
const entry = entries[pageIndex]
|
|
||||||
const buffer = entry.getData()
|
|
||||||
const ext = path.extname(entry.entryName).toLowerCase()
|
|
||||||
return { buffer, ext }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
164
src/lib/db.ts
164
src/lib/db.ts
@@ -12,12 +12,7 @@ export function getDb(): Database.Database {
|
|||||||
_db = new Database(DB_PATH)
|
_db = new Database(DB_PATH)
|
||||||
_db.pragma('journal_mode = WAL')
|
_db.pragma('journal_mode = WAL')
|
||||||
_db.pragma('foreign_keys = ON')
|
_db.pragma('foreign_keys = ON')
|
||||||
_db.pragma('busy_timeout = 5000')
|
|
||||||
_db.pragma('synchronous = NORMAL')
|
|
||||||
_db.pragma('cache_size = -65536')
|
|
||||||
_db.pragma('wal_autocheckpoint = 1000')
|
|
||||||
initDb(_db)
|
initDb(_db)
|
||||||
_db.pragma('wal_checkpoint(PASSIVE)')
|
|
||||||
return _db
|
return _db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,14 +106,6 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateMediaItemsAiFields(db)
|
migrateMediaItemsAiFields(db)
|
||||||
migrateLibraryAiSettings(db)
|
migrateLibraryAiSettings(db)
|
||||||
migrateAiJobs(db)
|
migrateAiJobs(db)
|
||||||
migrateLibraryPermissionsAccessLevel(db)
|
|
||||||
migrateLibrariesAddComics(db)
|
|
||||||
migrateComicItemTypes(db)
|
|
||||||
migrateImportedTags(db)
|
|
||||||
migrateComicsIndex(db)
|
|
||||||
migrateTagMappingsIndexes(db)
|
|
||||||
migrateUserRating(db)
|
|
||||||
migrateParentKeyItemTypeIndex(db)
|
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +119,6 @@ function seedAppSettings(db: Database.Database): void {
|
|||||||
ai_model: '',
|
ai_model: '',
|
||||||
preferred_language: 'English',
|
preferred_language: 'English',
|
||||||
ai_max_retries: '3',
|
ai_max_retries: '3',
|
||||||
ai_max_tokens_tag: '8192',
|
|
||||||
ai_max_tokens_describe: '8192',
|
|
||||||
ai_max_tokens_extract: '8192',
|
|
||||||
ai_max_tokens_translate: '8192',
|
|
||||||
}
|
}
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
|
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
|
||||||
@@ -293,19 +276,6 @@ function migrateLibraryAiSettings(db: Database.Database): void {
|
|||||||
prompt_translate TEXT
|
prompt_translate TEXT
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Add max_tokens columns if they don't exist yet
|
|
||||||
const row = db
|
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_ai_settings'")
|
|
||||||
.get() as { sql: string } | undefined
|
|
||||||
if (row && !row.sql.includes('max_tokens_tag')) {
|
|
||||||
db.exec(`
|
|
||||||
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_tag INTEGER;
|
|
||||||
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_describe INTEGER;
|
|
||||||
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_extract INTEGER;
|
|
||||||
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_translate INTEGER;
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateLibrariesType(db: Database.Database): void {
|
function migrateLibrariesType(db: Database.Database): void {
|
||||||
@@ -331,77 +301,6 @@ function migrateLibrariesType(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateLibrariesAddComics(db: Database.Database): void {
|
|
||||||
const row = db
|
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
|
||||||
.get() as { sql: string } | undefined
|
|
||||||
if (!row || row.sql.includes("'comics'")) return
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
CREATE TABLE libraries_new (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL CHECK(type IN ('comics','games','mixed','movies','tv')),
|
|
||||||
cover_ext TEXT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO libraries_new SELECT * FROM libraries;
|
|
||||||
DROP TABLE libraries;
|
|
||||||
ALTER TABLE libraries_new RENAME TO libraries;
|
|
||||||
COMMIT;
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateComicItemTypes(db: Database.Database): void {
|
|
||||||
const row = db
|
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
|
|
||||||
.get() as { sql: string } | undefined
|
|
||||||
if (!row || row.sql.includes("'comic_series'")) return
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
CREATE TABLE media_items_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
||||||
item_key TEXT NOT NULL UNIQUE,
|
|
||||||
item_type TEXT NOT NULL CHECK(item_type IN (
|
|
||||||
'movie','tv_series','tv_season','tv_episode',
|
|
||||||
'game','game_series','mixed_file',
|
|
||||||
'comic_series','comic_issue')),
|
|
||||||
parent_key TEXT,
|
|
||||||
title TEXT,
|
|
||||||
year INTEGER,
|
|
||||||
plot TEXT,
|
|
||||||
genres TEXT,
|
|
||||||
metadata TEXT,
|
|
||||||
file_path TEXT,
|
|
||||||
fingerprint TEXT,
|
|
||||||
scanned_at INTEGER NOT NULL,
|
|
||||||
ai_tagged_at INTEGER,
|
|
||||||
ai_description TEXT,
|
|
||||||
extracted_text TEXT,
|
|
||||||
extracted_text_translated TEXT
|
|
||||||
);
|
|
||||||
INSERT INTO media_items_new SELECT * FROM media_items;
|
|
||||||
DROP TABLE media_items;
|
|
||||||
ALTER TABLE media_items_new RENAME TO media_items;
|
|
||||||
CREATE INDEX media_items_library_id ON media_items(library_id);
|
|
||||||
CREATE INDEX media_items_parent_key ON media_items(parent_key);
|
|
||||||
CREATE INDEX media_items_fingerprint ON media_items(fingerprint);
|
|
||||||
COMMIT;
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
|
|
||||||
const row = db
|
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
|
|
||||||
.get() as { sql: string } | undefined
|
|
||||||
if (row && !row.sql.includes('access_level')) {
|
|
||||||
db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateAiJobs(db: Database.Database): void {
|
function migrateAiJobs(db: Database.Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS ai_jobs (
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
||||||
@@ -422,67 +321,4 @@ function migrateAiJobs(db: Database.Database): void {
|
|||||||
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
||||||
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Add payload column if not present
|
|
||||||
const aiJobsRow = db
|
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'")
|
|
||||||
.get() as { sql: string } | undefined
|
|
||||||
if (aiJobsRow && !aiJobsRow.sql.includes('payload')) {
|
|
||||||
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateImportedTags(db: Database.Database): void {
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS imported_tags (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
UNIQUE(library_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_imported_tags (
|
|
||||||
item_key TEXT NOT NULL,
|
|
||||||
imported_tag_id TEXT NOT NULL REFERENCES imported_tags(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (item_key, imported_tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tag_mappings (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
||||||
imported_tag_name TEXT NOT NULL,
|
|
||||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(library_id, imported_tag_name)
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateComicsIndex(db: Database.Database): void {
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS media_items_library_type_title
|
|
||||||
ON media_items(library_id, item_type, title);
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateTagMappingsIndexes(db: Database.Database): void {
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS tag_mappings_library_id ON tag_mappings(library_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS tag_mappings_tag_id ON tag_mappings(tag_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS imported_tags_library_id ON imported_tags(library_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS item_imported_tags_imported_tag_id ON item_imported_tags(imported_tag_id);
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateParentKeyItemTypeIndex(db: Database.Database): void {
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS media_items_parent_key_type
|
|
||||||
ON media_items(parent_key, item_type);
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateUserRating(db: Database.Database): void {
|
|
||||||
const cols = db.pragma('table_info(media_items)') as { name: string }[]
|
|
||||||
if (!cols.some((c) => c.name === 'user_rating')) {
|
|
||||||
db.exec('ALTER TABLE media_items ADD COLUMN user_rating INTEGER')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,16 +74,12 @@ export function scanDirectory(
|
|||||||
* Recursively walks every subdirectory under `subpath` and returns a flat list
|
* Recursively walks every subdirectory under `subpath` and returns a flat list
|
||||||
* of all files. Directory entries are omitted. Each FileEntry.name is the full
|
* of all files. Directory entries are omitted. Each FileEntry.name is the full
|
||||||
* relative path from the library root (e.g. FolderA/SubFolder/video.mp4).
|
* relative path from the library root (e.g. FolderA/SubFolder/video.mp4).
|
||||||
*
|
|
||||||
* Uses async I/O so the Node.js event loop is not blocked during large
|
|
||||||
* directory trees (blocking stalls streaming responses and causes
|
|
||||||
* "ReadableStream is already closed" errors on concurrent requests).
|
|
||||||
*/
|
*/
|
||||||
export async function scanDirectoryRecursive(
|
export function scanDirectoryRecursive(
|
||||||
libraryRoot: string,
|
libraryRoot: string,
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
subpath: string
|
subpath: string
|
||||||
): Promise<DirectoryListing> {
|
): DirectoryListing {
|
||||||
let rootAbsPath: string
|
let rootAbsPath: string
|
||||||
try {
|
try {
|
||||||
rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot
|
rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot
|
||||||
@@ -93,37 +89,35 @@ export async function scanDirectoryRecursive(
|
|||||||
|
|
||||||
const entries: FileEntry[] = []
|
const entries: FileEntry[] = []
|
||||||
|
|
||||||
async function walk(absDir: string, relDir: string): Promise<void> {
|
function walk(absDir: string, relDir: string): void {
|
||||||
let dirents: fs.Dirent[]
|
let dirents: fs.Dirent[]
|
||||||
try {
|
try {
|
||||||
dirents = await fs.promises.readdir(absDir, { withFileTypes: true })
|
dirents = fs.readdirSync(absDir, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await Promise.all(
|
for (const d of dirents) {
|
||||||
dirents.map(async (d) => {
|
if (HIDDEN_FILES.test(d.name)) continue
|
||||||
if (HIDDEN_FILES.test(d.name)) return
|
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
||||||
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
if (d.isDirectory()) {
|
||||||
if (d.isDirectory()) {
|
walk(path.join(absDir, d.name), relPath)
|
||||||
await walk(path.join(absDir, d.name), relPath)
|
} else {
|
||||||
} else {
|
const mediaType = getMediaType(d.name)
|
||||||
const mediaType = getMediaType(d.name)
|
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
||||||
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
// name = full relative path from library root so media keys match
|
||||||
// name = full relative path from library root so media keys match
|
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
||||||
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
entries.push({
|
||||||
entries.push({
|
name: fullRelPath,
|
||||||
name: fullRelPath,
|
type: 'file',
|
||||||
type: 'file',
|
mediaType,
|
||||||
mediaType,
|
url: fileApiUrl(libraryId, fullRelPath),
|
||||||
url: fileApiUrl(libraryId, fullRelPath),
|
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
||||||
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
})
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await walk(rootAbsPath, '')
|
walk(rootAbsPath, '')
|
||||||
entries.sort((a, b) => a.name.localeCompare(b.name))
|
entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return { path: subpath, entries }
|
return { path: subpath, entries }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,10 +93,6 @@ function buildGame(
|
|||||||
: null,
|
: null,
|
||||||
gameFiles,
|
gameFiles,
|
||||||
platforms,
|
platforms,
|
||||||
userRating: null,
|
|
||||||
aiDescription: null,
|
|
||||||
extractedText: null,
|
|
||||||
extractedTextTranslated: null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,15 +175,10 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
parent_key: string | null
|
parent_key: string | null
|
||||||
title: string | null
|
title: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
user_rating: number | null
|
|
||||||
ai_description: string | null
|
|
||||||
extracted_text: string | null
|
|
||||||
extracted_text_translated: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRows = db
|
const allRows = db
|
||||||
.prepare(`SELECT item_key, item_type, parent_key, title, metadata,
|
.prepare(`SELECT item_key, item_type, parent_key, title, metadata
|
||||||
user_rating, ai_description, extracted_text, extracted_text_translated
|
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE library_id = ? AND item_type IN ('game', 'game_series')
|
WHERE library_id = ? AND item_type IN ('game', 'game_series')
|
||||||
ORDER BY title`)
|
ORDER BY title`)
|
||||||
@@ -242,10 +233,6 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
wideCoverUrl: meta.wideCoverUrl ?? null,
|
wideCoverUrl: meta.wideCoverUrl ?? null,
|
||||||
gameFiles,
|
gameFiles,
|
||||||
platforms,
|
platforms,
|
||||||
userRating: row.user_rating ?? null,
|
|
||||||
aiDescription: row.ai_description ?? null,
|
|
||||||
extractedText: row.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
||||||
}
|
}
|
||||||
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
||||||
seriesMap.get(row.parent_key)!.games.push(game)
|
seriesMap.get(row.parent_key)!.games.push(game)
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import type { Library } from '@/types'
|
|
||||||
import { getDb } from './db'
|
|
||||||
import { resolveLibraryRoot } from './libraries'
|
|
||||||
import { parseMovieNfo } from './nfo'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import NFO metadata for Movie items in a library.
|
|
||||||
* - Reads .nfo file matching each movie file
|
|
||||||
* - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres)
|
|
||||||
* - If importMetadataOnly=true: update all items regardless of existing metadata
|
|
||||||
*/
|
|
||||||
export async function importMovieMetadata(
|
|
||||||
library: Library,
|
|
||||||
importMetadataOnly: boolean = false
|
|
||||||
): Promise<{ imported: number; skipped: number }> {
|
|
||||||
const db = getDb()
|
|
||||||
const libraryRoot = resolveLibraryRoot(library)
|
|
||||||
|
|
||||||
let imported = 0
|
|
||||||
let skipped = 0
|
|
||||||
|
|
||||||
// Get all movies in the library
|
|
||||||
const movies = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
|
||||||
WHERE library_id = ? AND item_type = 'movie' AND file_path IS NOT NULL`
|
|
||||||
)
|
|
||||||
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
|
||||||
|
|
||||||
const updateItem = db.prepare(`
|
|
||||||
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
|
||||||
WHERE item_key = @item_key
|
|
||||||
`)
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50
|
|
||||||
for (let i = 0; i < movies.length; i += BATCH_SIZE) {
|
|
||||||
const batch = movies.slice(i, i + BATCH_SIZE)
|
|
||||||
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const item of batch) {
|
|
||||||
// Check if we should skip this item
|
|
||||||
if (!importMetadataOnly && hasMetadata(item)) {
|
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoPath = path.join(libraryRoot, item.file_path)
|
|
||||||
const dir = path.dirname(videoPath)
|
|
||||||
const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath))
|
|
||||||
const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(nfoPath)) {
|
|
||||||
const nfoData = parseMovieNfo(nfoPath)
|
|
||||||
if (nfoData) {
|
|
||||||
updateItem.run({
|
|
||||||
item_key: item.item_key,
|
|
||||||
title: nfoData.title ?? item.title,
|
|
||||||
year: nfoData.year ?? item.year,
|
|
||||||
plot: nfoData.plot ?? item.plot,
|
|
||||||
genres: nfoData.genres.length > 0 ? JSON.stringify(nfoData.genres) : item.genres,
|
|
||||||
})
|
|
||||||
imported++
|
|
||||||
} else {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
await new Promise<void>((r) => setImmediate(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[movie-metadata] Imported metadata for ${imported} movies in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})`
|
|
||||||
)
|
|
||||||
|
|
||||||
return { imported, skipped }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a media item already has metadata populated.
|
|
||||||
* Returns true if ANY of: title, year, plot, or genres are populated.
|
|
||||||
*/
|
|
||||||
function hasMetadata(item: {
|
|
||||||
title: string | null
|
|
||||||
year: number | null
|
|
||||||
plot: string | null
|
|
||||||
genres: string | null
|
|
||||||
}): boolean {
|
|
||||||
if (item.title) return true
|
|
||||||
if (item.year) return true
|
|
||||||
if (item.plot) return true
|
|
||||||
if (item.genres) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -72,10 +72,6 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
|
|||||||
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
: null,
|
: null,
|
||||||
videoPath: videoRelPath,
|
videoPath: videoRelPath,
|
||||||
userRating: null,
|
|
||||||
aiDescription: null,
|
|
||||||
extractedText: null,
|
|
||||||
extractedTextTranslated: null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +90,6 @@ export function moviesFromDb(libraryId: string): Movie[] {
|
|||||||
genres: string | null
|
genres: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: string | null
|
file_path: string | null
|
||||||
user_rating: number | null
|
|
||||||
ai_description: string | null
|
|
||||||
extracted_text: string | null
|
|
||||||
extracted_text_translated: string | null
|
|
||||||
}>
|
}>
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
@@ -116,10 +108,6 @@ export function moviesFromDb(libraryId: string): Movie[] {
|
|||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
videoPath: row.file_path ?? '',
|
videoPath: row.file_path ?? '',
|
||||||
manuallyEdited: meta.manuallyEdited === true,
|
manuallyEdited: meta.manuallyEdited === true,
|
||||||
userRating: row.user_rating ?? null,
|
|
||||||
aiDescription: row.ai_description ?? null,
|
|
||||||
extractedText: row.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type Database from 'better-sqlite3'
|
import type Database from 'better-sqlite3'
|
||||||
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries, ComicIssue } from '@/types'
|
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getLibraries, resolveLibraryRoot } from './libraries'
|
import { getLibraries, resolveLibraryRoot } from './libraries'
|
||||||
import { setScanLastRan } from './app-settings'
|
import { setScanLastRan } from './app-settings'
|
||||||
import { scanMoviesLibrary } from './movies'
|
import { scanMoviesLibrary } from './movies'
|
||||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
||||||
import { scanGamesLibrary } from './games'
|
import { scanGamesLibrary } from './games'
|
||||||
import { scanComicsLibrary, type ScannedComicSeries } from './comics'
|
import { getThumbnailPath } from './thumbnails'
|
||||||
import { getThumbnailPath, getCbzThumbnailPath } from './thumbnails'
|
|
||||||
import { computeFingerprint } from './fingerprint'
|
import { computeFingerprint } from './fingerprint'
|
||||||
import { reKeyMediaItem } from './tags'
|
import { reKeyMediaItem } from './tags'
|
||||||
import { runAiTagging } from './ai-tagger'
|
import { runAiTagging } from './ai-tagger'
|
||||||
import { importComicMetadata } from './comic-metadata'
|
|
||||||
import { importTvMetadata } from './tv-metadata'
|
|
||||||
import { importMovieMetadata } from './movie-metadata'
|
|
||||||
|
|
||||||
let scanRunning = false
|
let scanRunning = false
|
||||||
|
|
||||||
@@ -74,9 +70,6 @@ export async function runLibraryScan(library: Library): Promise<void> {
|
|||||||
case 'mixed':
|
case 'mixed':
|
||||||
await scanMixed(library, libraryRoot)
|
await scanMixed(library, libraryRoot)
|
||||||
break
|
break
|
||||||
case 'comics':
|
|
||||||
await scanComics(library, libraryRoot)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await runAiTagging(library, libraryRoot).catch((err) =>
|
await runAiTagging(library, libraryRoot).catch((err) =>
|
||||||
@@ -543,208 +536,6 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
|
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Comics (clear+upsert pattern — CBZ files are immutable archives)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function scanComics(library: Library, libraryRoot: string): Promise<void> {
|
|
||||||
const items = await scanComicsLibrary(libraryRoot, library.id)
|
|
||||||
const db = getDb()
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
// Save ComicInfo metadata for issues that were already imported so we can
|
|
||||||
// restore it after the clear+upsert without re-reading any CBZ files.
|
|
||||||
type SavedInfo = { title: string | null; year: number | null; genres: string | null; comicFields: Record<string, unknown> }
|
|
||||||
const savedComicInfo = new Map<string, SavedInfo>()
|
|
||||||
{
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, title, year, genres, metadata FROM media_items
|
|
||||||
WHERE library_id = ? AND item_type = 'comic_issue'
|
|
||||||
AND (year IS NOT NULL OR genres IS NOT NULL)`
|
|
||||||
)
|
|
||||||
.all(library.id) as { item_key: string; title: string | null; year: number | null; genres: string | null; metadata: string | null }[]
|
|
||||||
for (const row of rows) {
|
|
||||||
const meta: Record<string, unknown> = row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : {}
|
|
||||||
savedComicInfo.set(row.item_key, {
|
|
||||||
title: row.title,
|
|
||||||
year: row.year,
|
|
||||||
genres: row.genres,
|
|
||||||
comicFields: {
|
|
||||||
writer: meta.writer,
|
|
||||||
publisher: meta.publisher,
|
|
||||||
translator: meta.translator,
|
|
||||||
web: meta.web,
|
|
||||||
month: meta.month,
|
|
||||||
day: meta.day,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearLibraryItems(db, library.id)
|
|
||||||
|
|
||||||
const upsertSeries = db.prepare(`
|
|
||||||
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, scanned_at)
|
|
||||||
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @scanned_at)
|
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
|
||||||
title = excluded.title,
|
|
||||||
metadata = excluded.metadata,
|
|
||||||
file_path = excluded.file_path,
|
|
||||||
scanned_at = excluded.scanned_at
|
|
||||||
`)
|
|
||||||
|
|
||||||
const upsertIssue = db.prepare(`
|
|
||||||
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, scanned_at)
|
|
||||||
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @scanned_at)
|
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
|
||||||
parent_key = excluded.parent_key,
|
|
||||||
title = excluded.title,
|
|
||||||
metadata = excluded.metadata,
|
|
||||||
file_path = excluded.file_path,
|
|
||||||
scanned_at = excluded.scanned_at
|
|
||||||
`)
|
|
||||||
|
|
||||||
type SeriesRec = Parameters<typeof upsertSeries.run>[0]
|
|
||||||
type IssueRec = Parameters<typeof upsertIssue.run>[0]
|
|
||||||
type BatchEntry = { type: 'series'; rec: SeriesRec } | { type: 'issue'; rec: IssueRec }
|
|
||||||
|
|
||||||
// Collect all records before touching the DB so we can batch-insert with event-loop yields.
|
|
||||||
// Note: between clearLibraryItems and the final batch, the library will appear partially
|
|
||||||
// populated — acceptable for a background scan.
|
|
||||||
const allRecords: BatchEntry[] = []
|
|
||||||
let issueCount = 0
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if ('issues' in item) {
|
|
||||||
const series = item as ScannedComicSeries
|
|
||||||
const seriesKey = `${library.id}:comic_series:${series.id}`
|
|
||||||
allRecords.push({
|
|
||||||
type: 'series',
|
|
||||||
rec: {
|
|
||||||
library_id: library.id,
|
|
||||||
item_key: seriesKey,
|
|
||||||
item_type: 'comic_series',
|
|
||||||
title: series.title,
|
|
||||||
metadata: JSON.stringify({ issueCount: series.issueCount, coverUrl: series.coverUrl }),
|
|
||||||
file_path: null,
|
|
||||||
scanned_at: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
for (const issue of series.issues) {
|
|
||||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
|
||||||
allRecords.push({
|
|
||||||
type: 'issue',
|
|
||||||
rec: {
|
|
||||||
library_id: library.id,
|
|
||||||
item_key: issueKey,
|
|
||||||
item_type: 'comic_issue',
|
|
||||||
parent_key: seriesKey,
|
|
||||||
title: issue.title,
|
|
||||||
metadata: JSON.stringify({
|
|
||||||
issueNumber: issue.issueNumber,
|
|
||||||
pageCount: issue.pageCount,
|
|
||||||
coverUrl: issue.coverUrl,
|
|
||||||
isStandalone: false,
|
|
||||||
}),
|
|
||||||
file_path: issue.filePath,
|
|
||||||
scanned_at: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
issueCount++
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const issue = item as ComicIssue
|
|
||||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
|
||||||
allRecords.push({
|
|
||||||
type: 'issue',
|
|
||||||
rec: {
|
|
||||||
library_id: library.id,
|
|
||||||
item_key: issueKey,
|
|
||||||
item_type: 'comic_issue',
|
|
||||||
parent_key: null,
|
|
||||||
title: issue.title,
|
|
||||||
metadata: JSON.stringify({
|
|
||||||
issueNumber: issue.issueNumber,
|
|
||||||
pageCount: issue.pageCount,
|
|
||||||
coverUrl: issue.coverUrl,
|
|
||||||
isStandalone: true,
|
|
||||||
}),
|
|
||||||
file_path: issue.filePath,
|
|
||||||
scanned_at: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
issueCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of item_key → fresh scan metadata (needed for ComicInfo restore below).
|
|
||||||
const freshMetaMap = new Map<string, Record<string, unknown>>()
|
|
||||||
for (const entry of allRecords) {
|
|
||||||
if (entry.type === 'issue') {
|
|
||||||
const rec = entry.rec as { item_key: unknown; metadata: unknown }
|
|
||||||
freshMetaMap.set(
|
|
||||||
String(rec.item_key),
|
|
||||||
JSON.parse(String(rec.metadata)) as Record<string, unknown>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert in batches of 500, yielding the event loop between batches so the app
|
|
||||||
// remains responsive to HTTP requests during a large scan.
|
|
||||||
const BATCH_SIZE = 500
|
|
||||||
for (let i = 0; i < allRecords.length; i += BATCH_SIZE) {
|
|
||||||
const batch = allRecords.slice(i, i + BATCH_SIZE)
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const entry of batch) {
|
|
||||||
if (entry.type === 'series') upsertSeries.run(entry.rec)
|
|
||||||
else upsertIssue.run(entry.rec)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
await new Promise<void>((r) => setImmediate(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore previously-imported ComicInfo data for issues that still exist on disk.
|
|
||||||
// Merges scan-derived fields (pageCount, coverUrl) with the saved ComicInfo fields
|
|
||||||
// so neither set of data is lost. Title from ComicInfo is also preserved.
|
|
||||||
if (savedComicInfo.size > 0) {
|
|
||||||
const restoreStmt = db.prepare(
|
|
||||||
'UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata WHERE item_key = @item_key'
|
|
||||||
)
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const [item_key, saved] of savedComicInfo) {
|
|
||||||
const freshMeta = freshMetaMap.get(item_key)
|
|
||||||
if (!freshMeta) continue // file was removed from disk
|
|
||||||
const merged = { ...freshMeta, ...saved.comicFields }
|
|
||||||
restoreStmt.run({ item_key, title: saved.title, year: saved.year, genres: saved.genres, metadata: JSON.stringify(merged) })
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prewarm CBZ cover thumbnails — fire-and-forget so they don't block scan completion.
|
|
||||||
for (const item of items) {
|
|
||||||
const issuesToWarm: ComicIssue[] = 'issues' in item
|
|
||||||
? (item as ScannedComicSeries).issues.slice(0, 1)
|
|
||||||
: [item as ComicIssue]
|
|
||||||
|
|
||||||
for (const issue of issuesToWarm) {
|
|
||||||
const absPath = path.join(libraryRoot, issue.filePath)
|
|
||||||
getCbzThumbnailPath(absPath, library.id).catch((err) => {
|
|
||||||
console.warn(`[scanner] Could not generate CBZ thumbnail for ${issue.filePath}:`, err instanceof Error ? err.message : err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[scanner] comics: indexed ${items.filter((i) => 'issues' in i).length} series, ${issueCount} issues`)
|
|
||||||
|
|
||||||
// Import ComicInfo.xml metadata (title, year, genres, tags)
|
|
||||||
try {
|
|
||||||
await importComicMetadata(library)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[scanner] Error importing comic metadata for "${library.name}":`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
102
src/lib/tags.ts
102
src/lib/tags.ts
@@ -98,62 +98,6 @@ export function deleteCategoryForce(id: string): void {
|
|||||||
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge all tags from `sourceId` category into `targetId` category, then
|
|
||||||
* delete the source category. Tags with conflicting names (case-insensitive)
|
|
||||||
* are combined: their media_tags and tag_mappings rows are re-pointed to the
|
|
||||||
* target tag, and the source tag is deleted.
|
|
||||||
*/
|
|
||||||
export function mergeCategories(sourceId: string, targetId: string): void {
|
|
||||||
const db = getDb()
|
|
||||||
|
|
||||||
const source = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(sourceId) as TagCategory | undefined
|
|
||||||
if (!source) throw new Error(`Source category not found: ${sourceId}`)
|
|
||||||
const target = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(targetId) as TagCategory | undefined
|
|
||||||
if (!target) throw new Error(`Target category not found: ${targetId}`)
|
|
||||||
|
|
||||||
const sourceTags = db
|
|
||||||
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?')
|
|
||||||
.all(sourceId) as Tag[]
|
|
||||||
const targetTags = db
|
|
||||||
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?')
|
|
||||||
.all(targetId) as Tag[]
|
|
||||||
|
|
||||||
const targetTagsByNameLower = new Map(targetTags.map((t) => [t.name.toLowerCase(), t]))
|
|
||||||
|
|
||||||
const txn = db.transaction(() => {
|
|
||||||
for (const srcTag of sourceTags) {
|
|
||||||
const conflict = targetTagsByNameLower.get(srcTag.name.toLowerCase())
|
|
||||||
if (conflict) {
|
|
||||||
// Re-point media_tags from source tag to target tag (ignore duplicates)
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR IGNORE INTO media_tags (item_key, tag_id)
|
|
||||||
SELECT item_key, ? FROM media_tags WHERE tag_id = ?`
|
|
||||||
).run(conflict.id, srcTag.id)
|
|
||||||
db.prepare('DELETE FROM media_tags WHERE tag_id = ?').run(srcTag.id)
|
|
||||||
|
|
||||||
// Re-point tag_mappings from source tag to target tag (ignore duplicates)
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE OR IGNORE tag_mappings SET tag_id = ? WHERE tag_id = ?`
|
|
||||||
).run(conflict.id, srcTag.id)
|
|
||||||
// Delete any remaining (were duplicates that couldn't be updated)
|
|
||||||
db.prepare('DELETE FROM tag_mappings WHERE tag_id = ?').run(srcTag.id)
|
|
||||||
|
|
||||||
// Delete the source tag
|
|
||||||
db.prepare('DELETE FROM tags WHERE id = ?').run(srcTag.id)
|
|
||||||
} else {
|
|
||||||
// No conflict — just move the tag to the target category
|
|
||||||
db.prepare('UPDATE tags SET category_id = ? WHERE id = ?').run(targetId, srcTag.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the now-empty source category
|
|
||||||
db.prepare('DELETE FROM tag_categories WHERE id = ?').run(sourceId)
|
|
||||||
})
|
|
||||||
|
|
||||||
txn()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getTags(categoryId?: string): Tag[] {
|
export function getTags(categoryId?: string): Tag[] {
|
||||||
@@ -319,52 +263,6 @@ export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns seriesItemKey -> { tagIds, issueTitles } aggregated from all issues in each series.
|
|
||||||
export function getComicsSeriesIssueMeta(
|
|
||||||
libraryId: string
|
|
||||||
): Record<string, { tagIds: string[]; issueTitles: string[] }> {
|
|
||||||
const db = getDb()
|
|
||||||
|
|
||||||
// All issues belonging to a series (parent_key is not null)
|
|
||||||
const issues = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, parent_key, title
|
|
||||||
FROM media_items
|
|
||||||
WHERE library_id = ? AND item_type = 'comic_issue' AND parent_key IS NOT NULL`
|
|
||||||
)
|
|
||||||
.all(libraryId) as { item_key: string; parent_key: string; title: string | null }[]
|
|
||||||
|
|
||||||
const result: Record<string, { tagIds: string[]; issueTitles: string[] }> = {}
|
|
||||||
const issueKeyToParent = new Map<string, string>()
|
|
||||||
|
|
||||||
for (const { item_key, parent_key, title } of issues) {
|
|
||||||
issueKeyToParent.set(item_key, parent_key)
|
|
||||||
const entry = (result[parent_key] ??= { tagIds: [], issueTitles: [] })
|
|
||||||
if (title) entry.issueTitles.push(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (issueKeyToParent.size === 0) return result
|
|
||||||
|
|
||||||
// Tag assignments for those issues
|
|
||||||
const tagRows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT mt.item_key, mt.tag_id
|
|
||||||
FROM media_tags mt
|
|
||||||
JOIN media_items mi ON mi.item_key = mt.item_key
|
|
||||||
WHERE mi.library_id = ? AND mi.item_type = 'comic_issue' AND mi.parent_key IS NOT NULL`
|
|
||||||
)
|
|
||||||
.all(libraryId) as { item_key: string; tag_id: string }[]
|
|
||||||
|
|
||||||
for (const { item_key, tag_id } of tagRows) {
|
|
||||||
const parentKey = issueKeyToParent.get(item_key)
|
|
||||||
if (!parentKey) continue
|
|
||||||
const entry = result[parentKey]
|
|
||||||
if (entry && !entry.tagIds.includes(tag_id)) entry.tagIds.push(tag_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)
|
db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { extractFirstZipImage } from './zip-utils'
|
|
||||||
|
|
||||||
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
||||||
const THUMBNAIL_WIDTH = 400
|
const THUMBNAIL_WIDTH = 400
|
||||||
@@ -61,19 +60,6 @@ async function generateAiImage(src: string, dest: string): Promise<void> {
|
|||||||
fs.renameSync(tmp, dest)
|
fs.renameSync(tmp, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a grayscale, contrast-normalised PNG for local OCR (Tesseract).
|
|
||||||
* PNG is lossless and avoids JPEG artefacts that can degrade OCR accuracy. */
|
|
||||||
async function generateOcrImage(src: string, dest: string): Promise<void> {
|
|
||||||
const tmp = dest + '.tmp'
|
|
||||||
await sharp(src)
|
|
||||||
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
|
|
||||||
.grayscale()
|
|
||||||
.normalise()
|
|
||||||
.png()
|
|
||||||
.toFile(tmp)
|
|
||||||
fs.renameSync(tmp, dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
|
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
|
||||||
function run(bin: string, args: string[]): Promise<void> {
|
function run(bin: string, args: string[]): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -204,51 +190,6 @@ export async function getAiImagePath(
|
|||||||
return cacheFile
|
return cacheFile
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the absolute path to a preprocessed PNG suitable for local OCR.
|
|
||||||
* The image is converted to grayscale and contrast-normalised for better
|
|
||||||
* Tesseract accuracy. Cached with an `_ocr` suffix.
|
|
||||||
*/
|
|
||||||
export async function getOcrImagePath(
|
|
||||||
absoluteFilePath: string,
|
|
||||||
libraryId: string
|
|
||||||
): Promise<string> {
|
|
||||||
ensureCacheDir()
|
|
||||||
const key = cacheKey(libraryId, absoluteFilePath)
|
|
||||||
const cacheFile = path.join(CACHE_DIR, key + '_ocr.png')
|
|
||||||
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
|
||||||
if (cached) return cached
|
|
||||||
await generateOcrImage(absoluteFilePath, cacheFile)
|
|
||||||
return cacheFile
|
|
||||||
}
|
|
||||||
|
|
||||||
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the absolute path to a cached thumbnail JPEG for a CBZ archive.
|
|
||||||
* Extracts the first image entry (natural sort) from the ZIP and resizes it.
|
|
||||||
* Throws on failure — callers should map this to a 404.
|
|
||||||
*/
|
|
||||||
export async function getCbzThumbnailPath(
|
|
||||||
absoluteFilePath: string,
|
|
||||||
libraryId: string
|
|
||||||
): Promise<string> {
|
|
||||||
ensureCacheDir()
|
|
||||||
|
|
||||||
const key = cacheKey(libraryId, absoluteFilePath)
|
|
||||||
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
|
|
||||||
|
|
||||||
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const buffer = await extractFirstZipImage(absoluteFilePath, CBZ_IMAGE_EXTENSIONS)
|
|
||||||
const tmp = cacheFile + '.tmp'
|
|
||||||
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: JPEG_QUALITY }).toFile(tmp)
|
|
||||||
fs.renameSync(tmp, cacheFile)
|
|
||||||
|
|
||||||
return cacheFile
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the absolute path to a cached thumbnail JPEG for the given file.
|
* Returns the absolute path to a cached thumbnail JPEG for the given file.
|
||||||
* Generates it on first call (or when the source has been modified).
|
* Generates it on first call (or when the source has been modified).
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import type { Library } from '@/types'
|
|
||||||
import { getDb } from './db'
|
|
||||||
import { resolveLibraryRoot } from './libraries'
|
|
||||||
import { parseEpisodeNfo } from './nfo'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import NFO metadata for TV items (series, seasons, episodes) in a library.
|
|
||||||
* - For series: reads tvshow.nfo in the series folder
|
|
||||||
* - For episodes: reads .nfo file matching the video file
|
|
||||||
* - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres)
|
|
||||||
* - If importMetadataOnly=true: update all items regardless of existing metadata
|
|
||||||
*/
|
|
||||||
export async function importTvMetadata(
|
|
||||||
library: Library,
|
|
||||||
importMetadataOnly: boolean = false
|
|
||||||
): Promise<{ imported: number; skipped: number }> {
|
|
||||||
const db = getDb()
|
|
||||||
const libraryRoot = resolveLibraryRoot(library)
|
|
||||||
|
|
||||||
let imported = 0
|
|
||||||
let skipped = 0
|
|
||||||
|
|
||||||
// Process TV series
|
|
||||||
const series = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
|
||||||
WHERE library_id = ? AND item_type = 'tv_series' AND file_path IS NOT NULL`
|
|
||||||
)
|
|
||||||
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
|
||||||
|
|
||||||
const updateSeriesItem = db.prepare(`
|
|
||||||
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
|
||||||
WHERE item_key = @item_key
|
|
||||||
`)
|
|
||||||
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const item of series) {
|
|
||||||
// Check if we should skip this item
|
|
||||||
if (!importMetadataOnly && hasMetadata(item)) {
|
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const seriesPath = path.join(libraryRoot, item.file_path)
|
|
||||||
const nfoPath = path.join(seriesPath, 'tvshow.nfo')
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(nfoPath)) {
|
|
||||||
const nfoData = parseEpisodeNfo(nfoPath) // Use episode parser as fallback, but mainly we need tvshow parser
|
|
||||||
// For now, we'll just mark as processed; series metadata comes from episodes usually
|
|
||||||
imported++
|
|
||||||
} else {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Process TV episodes
|
|
||||||
const episodes = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
|
||||||
WHERE library_id = ? AND item_type = 'tv_episode' AND file_path IS NOT NULL`
|
|
||||||
)
|
|
||||||
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
|
||||||
|
|
||||||
const updateEpisodeItem = db.prepare(`
|
|
||||||
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
|
||||||
WHERE item_key = @item_key
|
|
||||||
`)
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50
|
|
||||||
for (let i = 0; i < episodes.length; i += BATCH_SIZE) {
|
|
||||||
const batch = episodes.slice(i, i + BATCH_SIZE)
|
|
||||||
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const item of batch) {
|
|
||||||
// Check if we should skip this item
|
|
||||||
if (!importMetadataOnly && hasMetadata(item)) {
|
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoPath = path.join(libraryRoot, item.file_path)
|
|
||||||
const dir = path.dirname(videoPath)
|
|
||||||
const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath))
|
|
||||||
const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(nfoPath)) {
|
|
||||||
const nfoData = parseEpisodeNfo(nfoPath)
|
|
||||||
if (nfoData) {
|
|
||||||
updateEpisodeItem.run({
|
|
||||||
item_key: item.item_key,
|
|
||||||
title: nfoData.title ?? item.title,
|
|
||||||
year: nfoData.aired ? new Date(nfoData.aired).getFullYear() : null,
|
|
||||||
plot: nfoData.plot ?? item.plot,
|
|
||||||
genres: item.genres, // Keep existing genres for episodes
|
|
||||||
})
|
|
||||||
imported++
|
|
||||||
} else {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
await new Promise<void>((r) => setImmediate(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[tv-metadata] Imported metadata for ${imported} episodes in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})`
|
|
||||||
)
|
|
||||||
|
|
||||||
return { imported, skipped }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a media item already has metadata populated.
|
|
||||||
* Returns true if ANY of: title, year, plot, or genres are populated.
|
|
||||||
*/
|
|
||||||
function hasMetadata(item: {
|
|
||||||
title: string | null
|
|
||||||
year: number | null
|
|
||||||
plot: string | null
|
|
||||||
genres: string | null
|
|
||||||
}): boolean {
|
|
||||||
if (item.title) return true
|
|
||||||
if (item.year) return true
|
|
||||||
if (item.plot) return true
|
|
||||||
if (item.genres) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import path from 'path'
|
|||||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
import { parseTvShowNfo } from './nfo'
|
|
||||||
|
|
||||||
function isVideoFile(name: string): boolean {
|
function isVideoFile(name: string): boolean {
|
||||||
return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase())
|
return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase())
|
||||||
@@ -53,7 +52,6 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
|||||||
|
|
||||||
const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
|
const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
|
||||||
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
|
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
|
||||||
const nfo = parseTvShowNfo(path.join(seriesPath, 'tvshow.nfo'))
|
|
||||||
|
|
||||||
const seasonDirs = readDirs(seriesPath)
|
const seasonDirs = readDirs(seriesPath)
|
||||||
const seasonDirCount = seasonDirs.filter((sd) => {
|
const seasonDirCount = seasonDirs.filter((sd) => {
|
||||||
@@ -69,11 +67,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
|||||||
|
|
||||||
series.push({
|
series.push({
|
||||||
id,
|
id,
|
||||||
title: nfo?.title ?? dirName,
|
title: dirName,
|
||||||
year: nfo?.year ?? null,
|
year: null,
|
||||||
plot: nfo?.plot ?? null,
|
plot: null,
|
||||||
genres: nfo?.genres ?? [],
|
genres: [],
|
||||||
status: nfo?.status ?? null,
|
status: null,
|
||||||
posterUrl: posterFile
|
posterUrl: posterFile
|
||||||
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||||
: null,
|
: null,
|
||||||
@@ -81,10 +79,6 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
|||||||
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
: null,
|
: null,
|
||||||
seasonCount,
|
seasonCount,
|
||||||
userRating: null,
|
|
||||||
aiDescription: null,
|
|
||||||
extractedText: null,
|
|
||||||
extractedTextTranslated: null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,10 +179,6 @@ export function scanTvEpisodes(
|
|||||||
rating: null,
|
rating: null,
|
||||||
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
||||||
videoPath: videoRelPath,
|
videoPath: videoRelPath,
|
||||||
userRating: null,
|
|
||||||
aiDescription: null,
|
|
||||||
extractedText: null,
|
|
||||||
extractedTextTranslated: null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,10 +202,6 @@ type DbRow = {
|
|||||||
genres: string | null
|
genres: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: string | null
|
file_path: string | null
|
||||||
user_rating: number | null
|
|
||||||
ai_description: string | null
|
|
||||||
extracted_text: string | null
|
|
||||||
extracted_text_translated: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
||||||
@@ -239,10 +225,6 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
|||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
seasonCount: meta.seasonCount ?? 0,
|
seasonCount: meta.seasonCount ?? 0,
|
||||||
manuallyEdited: meta.manuallyEdited === true,
|
manuallyEdited: meta.manuallyEdited === true,
|
||||||
userRating: row.user_rating ?? null,
|
|
||||||
aiDescription: row.ai_description ?? null,
|
|
||||||
extractedText: row.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -306,10 +288,6 @@ export function tvEpisodesFromDb(
|
|||||||
rating: meta.rating ?? null,
|
rating: meta.rating ?? null,
|
||||||
thumbnailUrl: meta.thumbnailUrl ?? null,
|
thumbnailUrl: meta.thumbnailUrl ?? null,
|
||||||
videoPath: row.file_path ?? '',
|
videoPath: row.file_path ?? '',
|
||||||
userRating: row.user_rating ?? null,
|
|
||||||
aiDescription: row.ai_description ?? null,
|
|
||||||
extractedText: row.extracted_text ?? null,
|
|
||||||
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|||||||
@@ -77,60 +77,43 @@ export function listUsers(): User[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryPermission {
|
export function getPermittedLibraryIds(userId: string): string[] {
|
||||||
libraryId: string
|
|
||||||
accessLevel: 'read' | 'write'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLibraryPermissions(userId: string): LibraryPermission[] {
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare('SELECT library_id, access_level FROM library_permissions WHERE user_id = ?')
|
.prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
|
||||||
.all(userId) as { library_id: string; access_level: string }[]
|
.all(userId) as { library_id: string }[]
|
||||||
return rows.map((r) => ({ libraryId: r.library_id, accessLevel: r.access_level as 'read' | 'write' }))
|
return rows.map((r) => r.library_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLibraryAccessLevel(userId: string, libraryId: string): 'read' | 'write' | null {
|
export function setLibraryPermissions(userId: string, libraryIds: string[]): void {
|
||||||
const db = getDb()
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT access_level FROM library_permissions WHERE user_id = ? AND library_id = ?')
|
|
||||||
.get(userId, libraryId) as { access_level: string } | undefined
|
|
||||||
if (!row) return null
|
|
||||||
return row.access_level as 'read' | 'write'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLibraryPermissions(userId: string, permissions: LibraryPermission[]): void {
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
|
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
|
||||||
const insert = db.prepare(
|
const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)')
|
||||||
'INSERT INTO library_permissions (user_id, library_id, access_level) VALUES (?, ?, ?)'
|
for (const libraryId of libraryIds) {
|
||||||
)
|
insert.run(userId, libraryId)
|
||||||
for (const { libraryId, accessLevel } of permissions) {
|
|
||||||
insert.run(userId, libraryId, accessLevel)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
tx()
|
tx()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
|
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
|
||||||
if (role === 'admin') return getLibraries().map((l) => ({ ...l, accessLevel: 'admin' as const }))
|
if (role === 'admin') return getLibraries()
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT l.id, l.name, l.path, l.type, l.cover_ext, lp.access_level
|
`SELECT l.id, l.name, l.path, l.type, l.cover_ext
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
INNER JOIN library_permissions lp ON lp.library_id = l.id
|
INNER JOIN library_permissions lp ON lp.library_id = l.id
|
||||||
WHERE lp.user_id = ?
|
WHERE lp.user_id = ?
|
||||||
ORDER BY l.name ASC`
|
ORDER BY l.name ASC`
|
||||||
)
|
)
|
||||||
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null; access_level: string }[]
|
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null }[]
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
path: r.path,
|
path: r.path,
|
||||||
type: r.type as Library['type'],
|
type: r.type as Library['type'],
|
||||||
coverExt: r.cover_ext,
|
coverExt: r.cover_ext,
|
||||||
accessLevel: r.access_level as 'read' | 'write',
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
import { open } from 'fs/promises'
|
|
||||||
import type { FileHandle } from 'fs/promises'
|
|
||||||
import zlib from 'zlib'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
|
|
||||||
const inflateRaw = promisify(zlib.inflateRaw)
|
|
||||||
|
|
||||||
const EOCD_SIG = 0x06054b50
|
|
||||||
const CD_SIG = 0x02014b50
|
|
||||||
const LFH_SIG = 0x04034b50
|
|
||||||
|
|
||||||
export interface CdEntry {
|
|
||||||
name: string
|
|
||||||
compressionMethod: number
|
|
||||||
compressedSize: number
|
|
||||||
uncompressedSize: number
|
|
||||||
localHeaderOffset: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a ZIP file's central directory without loading the entire archive.
|
|
||||||
* Returns null if no EOCD record is found (corrupt/non-ZIP file).
|
|
||||||
* Returns an empty array for a valid but empty archive.
|
|
||||||
*/
|
|
||||||
async function readCentralDirectory(fd: FileHandle, fileSize: number): Promise<CdEntry[] | null> {
|
|
||||||
if (fileSize < 22) return null
|
|
||||||
|
|
||||||
// The EOCD record is within the last 65558 bytes (22-byte record + 65535-byte max comment).
|
|
||||||
const tailLen = Math.min(65558, fileSize)
|
|
||||||
const tailBuf = Buffer.allocUnsafe(tailLen)
|
|
||||||
await fd.read(tailBuf, 0, tailLen, fileSize - tailLen)
|
|
||||||
|
|
||||||
// Scan backwards for the EOCD signature.
|
|
||||||
let eocdOff = -1
|
|
||||||
for (let i = tailLen - 22; i >= 0; i--) {
|
|
||||||
if (tailBuf.readUInt32LE(i) === EOCD_SIG) { eocdOff = i; break }
|
|
||||||
}
|
|
||||||
if (eocdOff === -1) return null // no EOCD → corrupt
|
|
||||||
|
|
||||||
const entryCount = tailBuf.readUInt16LE(eocdOff + 10)
|
|
||||||
const cdSize = tailBuf.readUInt32LE(eocdOff + 12)
|
|
||||||
const cdOffset = tailBuf.readUInt32LE(eocdOff + 16)
|
|
||||||
if (entryCount === 0) return [] // valid empty archive
|
|
||||||
if (cdOffset + cdSize > fileSize || cdSize === 0) return null // malformed
|
|
||||||
|
|
||||||
const cdBuf = Buffer.allocUnsafe(cdSize)
|
|
||||||
await fd.read(cdBuf, 0, cdSize, cdOffset)
|
|
||||||
|
|
||||||
const entries: CdEntry[] = []
|
|
||||||
let pos = 0
|
|
||||||
for (let i = 0; i < entryCount && pos + 46 <= cdBuf.length; i++) {
|
|
||||||
if (cdBuf.readUInt32LE(pos) !== CD_SIG) break
|
|
||||||
const compressionMethod = cdBuf.readUInt16LE(pos + 10)
|
|
||||||
const compressedSize = cdBuf.readUInt32LE(pos + 20)
|
|
||||||
const uncompressedSize = cdBuf.readUInt32LE(pos + 24)
|
|
||||||
const filenameLen = cdBuf.readUInt16LE(pos + 28)
|
|
||||||
const extraLen = cdBuf.readUInt16LE(pos + 30)
|
|
||||||
const commentLen = cdBuf.readUInt16LE(pos + 32)
|
|
||||||
const localHeaderOffset = cdBuf.readUInt32LE(pos + 42)
|
|
||||||
const name = cdBuf.toString('utf8', pos + 46, pos + 46 + filenameLen)
|
|
||||||
entries.push({ name, compressionMethod, compressedSize, uncompressedSize, localHeaderOffset })
|
|
||||||
pos += 46 + filenameLen + extraLen + commentLen
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Thrown when a ZIP archive has no valid End-of-Central-Directory record. */
|
|
||||||
export class CorruptZipError extends Error {
|
|
||||||
readonly code = 'ERR_CORRUPT_ZIP'
|
|
||||||
constructor(absolutePath: string) {
|
|
||||||
super(`Corrupt or invalid ZIP archive: ${absolutePath}`)
|
|
||||||
this.name = 'CorruptZipError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCorruptZipError(err: unknown): err is CorruptZipError {
|
|
||||||
return err instanceof CorruptZipError ||
|
|
||||||
(err instanceof Error && (err as CorruptZipError).code === 'ERR_CORRUPT_ZIP')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the number of image entries inside a ZIP/CBZ archive by reading
|
|
||||||
* only its central directory — no full-file read required.
|
|
||||||
* Returns { pageCount, valid } where valid=false means the archive has no
|
|
||||||
* valid EOCD record (corrupt file).
|
|
||||||
*/
|
|
||||||
export async function countZipImages(
|
|
||||||
absolutePath: string,
|
|
||||||
imageExtensions: Set<string>
|
|
||||||
): Promise<{ pageCount: number; valid: boolean }> {
|
|
||||||
let fd: FileHandle | null = null
|
|
||||||
try {
|
|
||||||
fd = await open(absolutePath, 'r')
|
|
||||||
const { size } = await fd.stat()
|
|
||||||
const entries = await readCentralDirectory(fd, size)
|
|
||||||
if (entries === null) return { pageCount: 0, valid: false }
|
|
||||||
const pageCount = entries.filter((e) => {
|
|
||||||
if (e.name.endsWith('/')) return false
|
|
||||||
const dot = e.name.lastIndexOf('.')
|
|
||||||
return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase())
|
|
||||||
}).length
|
|
||||||
return { pageCount, valid: true }
|
|
||||||
} catch {
|
|
||||||
return { pageCount: 0, valid: false }
|
|
||||||
} finally {
|
|
||||||
await fd?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the raw bytes of a specific entry from a ZIP archive.
|
|
||||||
* Reads only the local file header + compressed data for that entry.
|
|
||||||
* Supports stored (method 0) and deflate (method 8).
|
|
||||||
*/
|
|
||||||
export async function extractZipEntry(absolutePath: string, entry: CdEntry): Promise<Buffer | null> {
|
|
||||||
let fd: FileHandle | null = null
|
|
||||||
try {
|
|
||||||
fd = await open(absolutePath, 'r')
|
|
||||||
|
|
||||||
// Read local file header (30 bytes) to get exact data offset.
|
|
||||||
const lfhBuf = Buffer.allocUnsafe(30)
|
|
||||||
await fd.read(lfhBuf, 0, 30, entry.localHeaderOffset)
|
|
||||||
if (lfhBuf.readUInt32LE(0) !== LFH_SIG) return null
|
|
||||||
const localFilenameLen = lfhBuf.readUInt16LE(26)
|
|
||||||
const localExtraLen = lfhBuf.readUInt16LE(28)
|
|
||||||
const dataOffset = entry.localHeaderOffset + 30 + localFilenameLen + localExtraLen
|
|
||||||
|
|
||||||
const compressedBuf = Buffer.allocUnsafe(entry.compressedSize)
|
|
||||||
await fd.read(compressedBuf, 0, entry.compressedSize, dataOffset)
|
|
||||||
|
|
||||||
if (entry.compressionMethod === 0) return compressedBuf
|
|
||||||
if (entry.compressionMethod === 8) return await inflateRaw(compressedBuf) as Buffer
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
await fd?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a named entry (case-insensitive) in a ZIP archive's central directory.
|
|
||||||
* Returns null if not found or on error.
|
|
||||||
*/
|
|
||||||
export async function findZipEntry(absolutePath: string, entryName: string): Promise<CdEntry | null> {
|
|
||||||
let fd: FileHandle | null = null
|
|
||||||
try {
|
|
||||||
fd = await open(absolutePath, 'r')
|
|
||||||
const { size } = await fd.stat()
|
|
||||||
const entries = await readCentralDirectory(fd, size)
|
|
||||||
if (!entries) return null
|
|
||||||
const lower = entryName.toLowerCase()
|
|
||||||
return entries.find((e) => {
|
|
||||||
const n = e.name.toLowerCase()
|
|
||||||
return n === lower || n.endsWith('/' + lower)
|
|
||||||
}) ?? null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
await fd?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the first image entry (natural sort) from a ZIP/CBZ archive.
|
|
||||||
* Reads only the central directory and the single chosen entry — no full-file load.
|
|
||||||
* Throws CorruptZipError if the archive has no valid structure.
|
|
||||||
*/
|
|
||||||
export async function extractFirstZipImage(
|
|
||||||
absolutePath: string,
|
|
||||||
imageExtensions: Set<string>
|
|
||||||
): Promise<Buffer> {
|
|
||||||
let fd: FileHandle | null = null
|
|
||||||
try {
|
|
||||||
fd = await open(absolutePath, 'r')
|
|
||||||
const { size } = await fd.stat()
|
|
||||||
const entries = await readCentralDirectory(fd, size)
|
|
||||||
if (entries === null) throw new CorruptZipError(absolutePath)
|
|
||||||
|
|
||||||
const imageEntries = entries
|
|
||||||
.filter((e) => {
|
|
||||||
if (e.name.endsWith('/')) return false
|
|
||||||
const dot = e.name.lastIndexOf('.')
|
|
||||||
return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase())
|
|
||||||
})
|
|
||||||
.sort((a, b) =>
|
|
||||||
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
|
||||||
)
|
|
||||||
|
|
||||||
if (imageEntries.length === 0) throw new Error(`No image entries in archive: ${absolutePath}`)
|
|
||||||
|
|
||||||
const entry = imageEntries[0]
|
|
||||||
|
|
||||||
// Read local file header to get the exact data offset.
|
|
||||||
const lfhBuf = Buffer.allocUnsafe(30)
|
|
||||||
await fd.read(lfhBuf, 0, 30, entry.localHeaderOffset)
|
|
||||||
if (lfhBuf.readUInt32LE(0) !== LFH_SIG) throw new CorruptZipError(absolutePath)
|
|
||||||
const localFilenameLen = lfhBuf.readUInt16LE(26)
|
|
||||||
const localExtraLen = lfhBuf.readUInt16LE(28)
|
|
||||||
const dataOffset = entry.localHeaderOffset + 30 + localFilenameLen + localExtraLen
|
|
||||||
|
|
||||||
const compressedBuf = Buffer.allocUnsafe(entry.compressedSize)
|
|
||||||
await fd.read(compressedBuf, 0, entry.compressedSize, dataOffset)
|
|
||||||
|
|
||||||
if (entry.compressionMethod === 0) return compressedBuf
|
|
||||||
if (entry.compressionMethod === 8) return await inflateRaw(compressedBuf) as Buffer
|
|
||||||
throw new Error(`Unsupported compression method ${entry.compressionMethod}: ${absolutePath}`)
|
|
||||||
} finally {
|
|
||||||
await fd?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an array of items concurrently with a concurrency limit.
|
|
||||||
* Preserves index order in results.
|
|
||||||
*/
|
|
||||||
export async function mapConcurrent<T, U>(
|
|
||||||
items: T[],
|
|
||||||
limit: number,
|
|
||||||
fn: (item: T) => Promise<U>
|
|
||||||
): Promise<U[]> {
|
|
||||||
const results: U[] = new Array(items.length)
|
|
||||||
let next = 0
|
|
||||||
async function worker(): Promise<void> {
|
|
||||||
while (next < items.length) {
|
|
||||||
const i = next++
|
|
||||||
results[i] = await fn(items[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,4 @@
|
|||||||
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
|
export type LibraryType = 'games' | 'mixed' | 'movies' | 'tv'
|
||||||
|
|
||||||
export type RatingOperator = 'gte' | 'eq' | 'lte'
|
|
||||||
|
|
||||||
export interface ComicSeries {
|
|
||||||
id: string
|
|
||||||
item_key?: string
|
|
||||||
title: string
|
|
||||||
coverUrl: string | null
|
|
||||||
issueCount: number
|
|
||||||
userRating: number | null
|
|
||||||
aiDescription: string | null
|
|
||||||
extractedText: string | null
|
|
||||||
extractedTextTranslated: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComicIssue {
|
|
||||||
id: string
|
|
||||||
item_key?: string
|
|
||||||
title: string
|
|
||||||
issueNumber: number | null
|
|
||||||
pageCount: number
|
|
||||||
coverUrl: string | null
|
|
||||||
filePath: string
|
|
||||||
isStandalone: boolean
|
|
||||||
userRating: number | null
|
|
||||||
aiDescription: string | null
|
|
||||||
extractedText: string | null
|
|
||||||
extractedTextTranslated: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,7 +6,6 @@ export interface Library {
|
|||||||
path: string
|
path: string
|
||||||
type: LibraryType
|
type: LibraryType
|
||||||
coverExt: string | null
|
coverExt: string | null
|
||||||
accessLevel?: 'admin' | 'read' | 'write'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'
|
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'
|
||||||
@@ -55,10 +25,6 @@ export interface Game {
|
|||||||
wideCoverUrl: string | null
|
wideCoverUrl: string | null
|
||||||
gameFiles: GameFile[]
|
gameFiles: GameFile[]
|
||||||
platforms: GamePlatform[]
|
platforms: GamePlatform[]
|
||||||
userRating: number | null
|
|
||||||
aiDescription: string | null
|
|
||||||
extractedText: string | null
|
|
||||||
extractedTextTranslated: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSeries {
|
export interface GameSeries {
|
||||||
@@ -78,11 +44,6 @@ export interface FileEntry {
|
|||||||
mediaType: MediaType | null
|
mediaType: MediaType | null
|
||||||
url: string | null
|
url: string | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
hasExtractedText?: boolean
|
|
||||||
userRating?: number | null
|
|
||||||
aiDescription?: string | null
|
|
||||||
extractedText?: string | null
|
|
||||||
extractedTextTranslated?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Movie {
|
export interface Movie {
|
||||||
@@ -98,10 +59,6 @@ export interface Movie {
|
|||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
videoPath: string
|
videoPath: string
|
||||||
manuallyEdited?: boolean
|
manuallyEdited?: boolean
|
||||||
userRating: number | null
|
|
||||||
aiDescription: string | null
|
|
||||||
extractedText: string | null
|
|
||||||
extractedTextTranslated: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeries {
|
export interface TvSeries {
|
||||||
@@ -116,10 +73,6 @@ export interface TvSeries {
|
|||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
seasonCount: number
|
seasonCount: number
|
||||||
manuallyEdited?: boolean
|
manuallyEdited?: boolean
|
||||||
userRating: number | null
|
|
||||||
aiDescription: string | null
|
|
||||||
extractedText: string | null
|
|
||||||
extractedTextTranslated: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeason {
|
export interface TvSeason {
|
||||||
@@ -143,10 +96,6 @@ export interface TvEpisode {
|
|||||||
rating: number | null
|
rating: number | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
videoPath: string
|
videoPath: string
|
||||||
userRating: number | null
|
|
||||||
aiDescription: string | null
|
|
||||||
extractedText: string | null
|
|
||||||
extractedTextTranslated: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectoryListing {
|
export interface DirectoryListing {
|
||||||
@@ -176,32 +125,3 @@ export interface UserSettings {
|
|||||||
tvLoop: boolean
|
tvLoop: boolean
|
||||||
tvMuted: boolean
|
tvMuted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComicInfoData {
|
|
||||||
title: string | null
|
|
||||||
year: number | null
|
|
||||||
month: number | null
|
|
||||||
day: number | null
|
|
||||||
writer: string | null
|
|
||||||
translator: string | null
|
|
||||||
publisher: string | null
|
|
||||||
genre: string | null
|
|
||||||
tags: string[]
|
|
||||||
web: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportedTag {
|
|
||||||
id: string
|
|
||||||
libraryId: string
|
|
||||||
name: string
|
|
||||||
itemCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagMapping {
|
|
||||||
id: string
|
|
||||||
libraryId: string
|
|
||||||
importedTagName: string
|
|
||||||
tagId: string
|
|
||||||
tagName?: string
|
|
||||||
categoryName?: string
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user