Compare commits
64 Commits
afb9540df2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f1ad4f5dd | |||
|
|
e283d03e95 | ||
|
|
0e600e5f6c | ||
|
|
2cf8bc6d7d | ||
| da3ad97d51 | |||
|
|
b5d144c8cc | ||
|
|
d854bbe99b | ||
| d2057fb81c | |||
|
|
27430dbf52 | ||
|
|
bd028a7a5d | ||
|
|
8f8f8c3001 | ||
|
|
dee9356004 | ||
| 7d2ae7e95c | |||
|
|
cedc012733 | ||
| a9461f9ae4 | |||
|
|
a6d657d87d | ||
|
|
71a026f01e | ||
| fc9a7af7c3 | |||
|
|
b12decc802 | ||
|
|
6c6a35433c | ||
|
|
0842769125 | ||
| 95bcaf53be | |||
|
|
b0e9c9790c | ||
|
|
fbcd592609 | ||
| 7b76e3d900 | |||
|
|
2ea02b197b | ||
|
|
8f84da7e2f | ||
|
|
625e256944 | ||
| 152bc12427 | |||
|
|
345a05e42a | ||
|
|
0de839393a | ||
|
|
0ff3ed8ac9 | ||
|
|
b2e9df8ab8 | ||
| b774cba046 | |||
|
|
5b5c3453d2 | ||
|
|
37dcb79546 | ||
| c2135747b5 | |||
|
|
afcf740f63 | ||
|
|
dae33a36bc | ||
|
|
a379e94bce | ||
|
|
0b03b937e0 | ||
|
|
19756c9eab | ||
| b25774d928 | |||
|
|
db2e446ef4 | ||
|
|
96cfb8aae7 | ||
|
|
d754f85717 | ||
| 9d73459f48 | |||
|
|
9b2690f639 | ||
|
|
1350a6f94b | ||
|
|
2fc9a34626 | ||
| 236f168eeb | |||
|
|
fea55594d0 | ||
|
|
8557c80c52 | ||
|
|
68b1ed94ea | ||
|
|
e31a9667ef | ||
| c454d020da | |||
|
|
b0fc275a52 | ||
|
|
cd9a83ea90 | ||
|
|
5ba73b2e56 | ||
| 2b51f72f96 | |||
|
|
efaff8ca1b | ||
|
|
89ac22e9d1 | ||
|
|
b0d146679f | ||
|
|
887cc05901 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ medialore.db-wal
|
|||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.session_secret
|
.session_secret
|
||||||
.vscode/
|
.vscode/
|
||||||
|
*.traineddata
|
||||||
@@ -45,6 +45,11 @@ 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'],
|
serverExternalPackages: ['better-sqlite3', 'sharp', 'tesseract.js'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
251
package-lock.json
generated
251
package-lock.json
generated
@@ -9,6 +9,9 @@
|
|||||||
"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",
|
||||||
@@ -17,7 +20,8 @@
|
|||||||
"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",
|
||||||
@@ -1143,9 +1147,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz",
|
||||||
"integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==",
|
"integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -1159,9 +1163,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz",
|
||||||
"integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==",
|
"integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1175,9 +1179,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz",
|
||||||
"integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==",
|
"integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1191,9 +1195,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz",
|
||||||
"integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==",
|
"integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1210,9 +1214,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz",
|
||||||
"integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==",
|
"integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1229,9 +1233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz",
|
||||||
"integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==",
|
"integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1248,9 +1252,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz",
|
||||||
"integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==",
|
"integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1267,9 +1271,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz",
|
||||||
"integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==",
|
"integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1283,9 +1287,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz",
|
||||||
"integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==",
|
"integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1655,6 +1659,33 @@
|
|||||||
"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",
|
||||||
@@ -1666,6 +1697,15 @@
|
|||||||
"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",
|
||||||
@@ -1711,7 +1751,6 @@
|
|||||||
"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"
|
||||||
@@ -2364,6 +2403,15 @@
|
|||||||
"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",
|
||||||
@@ -2950,10 +2998,16 @@
|
|||||||
"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.12",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4803,6 +4857,12 @@
|
|||||||
"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",
|
||||||
@@ -5288,6 +5348,12 @@
|
|||||||
"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",
|
||||||
@@ -6038,12 +6104,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
|
||||||
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
|
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.14",
|
"@next/env": "15.5.15",
|
||||||
"@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",
|
||||||
@@ -6056,14 +6122,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.14",
|
"@next/swc-darwin-arm64": "15.5.15",
|
||||||
"@next/swc-darwin-x64": "15.5.14",
|
"@next/swc-darwin-x64": "15.5.15",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.14",
|
"@next/swc-linux-arm64-gnu": "15.5.15",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.14",
|
"@next/swc-linux-arm64-musl": "15.5.15",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.14",
|
"@next/swc-linux-x64-gnu": "15.5.15",
|
||||||
"@next/swc-linux-x64-musl": "15.5.14",
|
"@next/swc-linux-x64-musl": "15.5.15",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.14",
|
"@next/swc-win32-arm64-msvc": "15.5.15",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.14",
|
"@next/swc-win32-x64-msvc": "15.5.15",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -6167,6 +6233,26 @@
|
|||||||
"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",
|
||||||
@@ -6315,6 +6401,15 @@
|
|||||||
"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",
|
||||||
@@ -6747,6 +6842,12 @@
|
|||||||
"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",
|
||||||
@@ -7585,6 +7686,30 @@
|
|||||||
"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",
|
||||||
@@ -7655,6 +7780,12 @@
|
|||||||
"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",
|
||||||
@@ -7870,7 +8001,6 @@
|
|||||||
"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": {
|
||||||
@@ -7955,6 +8085,28 @@
|
|||||||
"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",
|
||||||
@@ -8237,6 +8389,15 @@
|
|||||||
"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,6 +12,9 @@
|
|||||||
"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",
|
||||||
@@ -20,7 +23,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
63
src/app/api/ai-jobs/route.ts
Normal file
63
src/app/api/ai-jobs/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getJobQueue, getJobHistory, retryJob, cancelJob, cancelAllQueued, clearJobHistory } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const queue = getJobQueue()
|
||||||
|
const history = getJobHistory(50)
|
||||||
|
return NextResponse.json({ queue, history })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
let body: { action?: string; jobId?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, jobId } = body
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'retry': {
|
||||||
|
if (!jobId || typeof jobId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const ok = retryJob(jobId)
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Job not found or not in failed state' }, { status: 404 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel': {
|
||||||
|
if (!jobId || typeof jobId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const ok = cancelJob(jobId)
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Job not found or not in queued state' }, { status: 404 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel-all': {
|
||||||
|
const cancelled = cancelAllQueued()
|
||||||
|
return NextResponse.json({ cancelled })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'clear-history': {
|
||||||
|
const cleared = clearJobHistory()
|
||||||
|
return NextResponse.json({ cleared })
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/api/ai-settings/library/[id]/route.ts
Normal file
48
src/app/api/ai-settings/library/[id]/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getLibraryAiOverrides, setLibraryAiOverrides } from '@/lib/app-settings'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
return NextResponse.json(getLibraryAiOverrides(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
let body: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
setLibraryAiOverrides(id, {
|
||||||
|
modelTagging: typeof body.modelTagging === 'string' ? body.modelTagging : undefined,
|
||||||
|
modelDescribe: typeof body.modelDescribe === 'string' ? body.modelDescribe : undefined,
|
||||||
|
modelExtract: typeof body.modelExtract === 'string' ? body.modelExtract : undefined,
|
||||||
|
modelTranslate: typeof body.modelTranslate === 'string' ? body.modelTranslate : undefined,
|
||||||
|
promptDescribe: typeof body.promptDescribe === 'string' ? body.promptDescribe : undefined,
|
||||||
|
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
|
||||||
|
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : 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))
|
||||||
|
}
|
||||||
11
src/app/api/ai-settings/ocr/route.ts
Normal file
11
src/app/api/ai-settings/ocr/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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,28 +1,57 @@
|
|||||||
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 } from '@/lib/app-settings'
|
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries, type OcrMode } 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)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled } = getAiConfig()
|
const config = getAiConfig()
|
||||||
const preferredLanguage = getPreferredLanguage()
|
const preferredLanguage = getPreferredLanguage()
|
||||||
return NextResponse.json({ endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled, preferredLanguage })
|
const maxRetries = getAiMaxRetries()
|
||||||
|
return NextResponse.json({ ...config, preferredLanguage, maxRetries })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
const auth = await requireAdmin(request)
|
const auth = await requireAdmin(request)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
let body: { endpoint?: string; model?: string; modelTagging?: string; modelDescribe?: string; modelExtract?: string; modelTranslate?: string; enabled?: boolean; preferredLanguage?: string }
|
let body: {
|
||||||
|
endpoint?: string
|
||||||
|
model?: string
|
||||||
|
modelTagging?: string
|
||||||
|
modelDescribe?: string
|
||||||
|
modelExtract?: string
|
||||||
|
modelTranslate?: string
|
||||||
|
enabled?: boolean
|
||||||
|
preferredLanguage?: string
|
||||||
|
promptDescribe?: string
|
||||||
|
promptTagger?: string
|
||||||
|
promptExtract?: string
|
||||||
|
promptTranslate?: string
|
||||||
|
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()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { endpoint, model, enabled, preferredLanguage, modelTagging, modelDescribe, modelExtract, modelTranslate } = body
|
const {
|
||||||
|
endpoint, model, enabled, preferredLanguage,
|
||||||
|
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
||||||
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
|
maxRetries,
|
||||||
|
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
||||||
|
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
||||||
|
} = body
|
||||||
|
|
||||||
if (typeof endpoint !== 'string') {
|
if (typeof endpoint !== 'string') {
|
||||||
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
|
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
|
||||||
@@ -42,12 +71,27 @@ export async function PUT(request: NextRequest) {
|
|||||||
typeof modelDescribe === 'string' ? modelDescribe : undefined,
|
typeof modelDescribe === 'string' ? modelDescribe : undefined,
|
||||||
typeof modelExtract === 'string' ? modelExtract : undefined,
|
typeof modelExtract === 'string' ? modelExtract : undefined,
|
||||||
typeof modelTranslate === 'string' ? modelTranslate : undefined,
|
typeof modelTranslate === 'string' ? modelTranslate : undefined,
|
||||||
|
typeof promptDescribe === 'string' ? promptDescribe : undefined,
|
||||||
|
typeof promptTagger === 'string' ? promptTagger : undefined,
|
||||||
|
typeof promptExtract === 'string' ? promptExtract : 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()) {
|
||||||
setPreferredLanguage(preferredLanguage.trim())
|
setPreferredLanguage(preferredLanguage.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof maxRetries === 'number' && Number.isFinite(maxRetries)) {
|
||||||
|
setAiMaxRetries(maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
const config = getAiConfig()
|
const config = getAiConfig()
|
||||||
return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage() })
|
return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage(), maxRetries: getAiMaxRetries() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { describeDirectoryItems } from '@/lib/ai-tagger'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg'])
|
||||||
|
const MEDIA_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS])
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { libraryId?: string; path?: string }
|
let body: { libraryId?: string; path?: string }
|
||||||
@@ -15,24 +19,9 @@ 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 requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
||||||
const processed = await describeDirectoryItems(libraryId, dirPath ?? '')
|
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||||
return NextResponse.json({ processed })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'INVALID_TYPE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/describe-bulk] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to generate descriptions' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { generateItemDescription } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string }
|
||||||
@@ -16,24 +16,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
||||||
const description = await generateItemDescription(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json({ description })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NO_IMAGE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/describe] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to generate description' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { extractDirectoryText } from '@/lib/ai-tagger'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { libraryId?: string; path?: string }
|
let body: { libraryId?: string; path?: string }
|
||||||
@@ -15,24 +17,9 @@ 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 requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
||||||
const processed = await extractDirectoryText(libraryId, dirPath ?? '')
|
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||||
return NextResponse.json({ processed })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'INVALID_TYPE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/extract-text-bulk] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to extract text' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { extractItemText } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: 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 } = body
|
const { itemKey, ocrLanguages, ocrMode } = 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 requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const payload: Record<string, string> = {}
|
||||||
const result = await extractItemText(itemKey)
|
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
|
||||||
return NextResponse.json(result)
|
if (ocrMode) payload.ocrMode = ocrMode
|
||||||
} catch (err) {
|
const jobId = enqueueJob(
|
||||||
const error = err as Error & { code?: string }
|
itemKey,
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
'extract',
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
libraryId,
|
||||||
}
|
undefined,
|
||||||
if (error.code === 'NOT_FOUND') {
|
Object.keys(payload).length ? payload : undefined,
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
)
|
||||||
}
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
if (error.code === 'NO_IMAGE' || error.code === 'INVALID_TYPE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/extract-text] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to extract text' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { getAiFields } from '@/lib/ai-tagger'
|
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -17,3 +17,39 @@ export async function GET(request: NextRequest) {
|
|||||||
const fields = getAiFields(itemKey)
|
const fields = getAiFields(itemKey)
|
||||||
return NextResponse.json(fields)
|
return NextResponse.json(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemKey, extractedText, aiDescription } = body
|
||||||
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (extractedText === undefined && aiDescription === undefined) {
|
||||||
|
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
if (extractedText !== undefined) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { tagSingleItem } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string }
|
||||||
@@ -16,24 +16,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
||||||
const tagIds = await tagSingleItem(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json({ tagIds })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NO_IMAGE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging] Error tagging item:', error)
|
|
||||||
return NextResponse.json({ error: 'AI tagging failed' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal file
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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,36 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { translateItemText } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string; sourceLanguage?: 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 } = body
|
const { itemKey, sourceLanguage } = 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 requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
||||||
const translatedText = await translateItemText(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json({ translatedText })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/translate] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to translate text' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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'
|
||||||
@@ -29,8 +30,72 @@ 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
|
||||||
? scanDirectoryRecursive(root, libraryId, subpath)
|
? await 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
src/app/api/comics/page/route.ts
Normal file
71
src/app/api/comics/page/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
117
src/app/api/comics/route.ts
Normal file
117
src/app/api/comics/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
26
src/app/api/comics/series-issue-tags/route.ts
Normal file
26
src/app/api/comics/series-issue-tags/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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,6 +20,7 @@ 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',
|
||||||
@@ -43,6 +44,7 @@ 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') ||
|
||||||
|
|||||||
16
src/app/api/imported-tags/route.ts
Normal file
16
src/app/api/imported-tags/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal file
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal file
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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()
|
? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' }))
|
||||||
: 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[] = ['games', 'mixed', 'movies', 'tv']
|
const validTypes: LibraryType[] = ['comics', '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,7 +120,46 @@ 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') {
|
||||||
|
|||||||
64
src/app/api/ratings/route.ts
Normal file
64
src/app/api/ratings/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
21
src/app/api/tag-mappings/[id]/route.ts
Normal file
21
src/app/api/tag-mappings/[id]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/tag-mappings/route.ts
Normal file
44
src/app/api/tag-mappings/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 } from '@/lib/auth'
|
import { requireLibraryAccess, requireLibraryWriteAccess } 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 requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(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 requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(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 } from '@/lib/tags'
|
import { updateCategory, deleteCategory, deleteCategoryForce, getTags, getCategories, mergeCategories } from '@/lib/tags'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
@@ -11,9 +11,30 @@ export async function PATCH(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { name } = await request.json()
|
const { name, merge } = await request.json()
|
||||||
const category = updateCategory(id, name)
|
|
||||||
return NextResponse.json(category)
|
try {
|
||||||
|
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,17 +1,20 @@
|
|||||||
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 } from '@/lib/thumbnails'
|
import { getThumbnailPath, getCbzThumbnailPath } 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' | null {
|
function getMediaType(filePath: string): 'image' | 'video' | 'cbz' | 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +46,13 @@ 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 and video files' }, { status: 400 })
|
return NextResponse.json({ error: 'Thumbnails are only supported for image, video, and CBZ files' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbnailPath = await getThumbnailPath(filePath, libraryId, mediaType)
|
const thumbnailPath = mediaType === 'cbz'
|
||||||
|
? 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)
|
||||||
|
|
||||||
@@ -60,7 +65,30 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Thumbnail generation failed for ${filePath}:`, err)
|
if (isCorruptZipError(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, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users'
|
import { getUserById, getLibraryPermissions, setLibraryPermissions, type LibraryPermission } 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 libraryIds = getPermittedLibraryIds(id)
|
const permissions = getLibraryPermissions(id)
|
||||||
return NextResponse.json({ libraryIds })
|
return NextResponse.json({ permissions })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -35,24 +35,41 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { libraryIds?: unknown }
|
let body: { permissions?: 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.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) {
|
if (!Array.isArray(body.permissions)) {
|
||||||
return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 })
|
return NextResponse.json({ error: 'permissions must be an array' }, { 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 = body.libraryIds.filter((id) => !validIds.has(id))
|
const invalid = permissions.filter((p) => !validIds.has(p.libraryId)).map((p) => p.libraryId)
|
||||||
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, body.libraryIds)
|
setLibraryPermissions(id, permissions)
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { getPermittedLibraryIds } from '@/lib/users'
|
import { getLibraryAccessLevel } 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'
|
||||||
@@ -23,32 +24,42 @@ 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 permitted = getPermittedLibraryIds(session.userId)
|
const accessLevel = getLibraryAccessLevel(session.userId, id)
|
||||||
if (!permitted.includes(id)) notFound()
|
if (!accessLevel) notFound()
|
||||||
|
readOnly = accessLevel === 'read'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-6">
|
{library.type !== 'mixed' && (
|
||||||
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center gap-2 mb-6">
|
||||||
Libraries
|
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
||||||
</a>
|
Libraries
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
</a>
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||||
{library.name}
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
</span>
|
{library.name}
|
||||||
{session.role === 'admin' && (
|
</span>
|
||||||
<div className="ml-auto">
|
{session.role === 'admin' && (
|
||||||
<ScanLibraryButton libraryId={id} />
|
<div className="ml-auto">
|
||||||
</div>
|
<ScanLibraryButton libraryId={id} />
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{library.type === 'mixed' && session.role === 'admin' && (
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<ScanLibraryButton libraryId={id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{library.type === 'games' && <GamesView libraryId={id} />}
|
{library.type === 'comics' && <ComicsView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
|
||||||
{library.type === 'tv' && <TvView libraryId={id} />}
|
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
|
||||||
|
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
interface AiSettings {
|
interface AiSettings {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
@@ -11,10 +11,76 @@ interface AiSettings {
|
|||||||
modelTranslate: string
|
modelTranslate: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
preferredLanguage: string
|
preferredLanguage: string
|
||||||
|
promptDescribe: string
|
||||||
|
promptTagger: string
|
||||||
|
promptExtract: string
|
||||||
|
promptTranslate: string
|
||||||
|
maxRetries: number
|
||||||
|
maxTokensTag: number
|
||||||
|
maxTokensDescribe: number
|
||||||
|
maxTokensExtract: number
|
||||||
|
maxTokensTranslate: number
|
||||||
|
ocrMode: 'hybrid' | 'tesseract' | 'llm'
|
||||||
|
ocrLanguages: string
|
||||||
|
ocrConfidenceThreshold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiJob {
|
||||||
|
id: string
|
||||||
|
itemKey: string
|
||||||
|
libraryId: string
|
||||||
|
jobType: string
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
maxRetries: number
|
||||||
|
createdAt: number
|
||||||
|
startedAt: number | null
|
||||||
|
completedAt: number | null
|
||||||
|
itemTitle: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Library {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibraryOverride {
|
||||||
|
modelTagging: string
|
||||||
|
modelDescribe: string
|
||||||
|
modelExtract: string
|
||||||
|
modelTranslate: string
|
||||||
|
promptDescribe: string
|
||||||
|
promptTagger: string
|
||||||
|
promptExtract: string
|
||||||
|
promptTranslate: string
|
||||||
|
maxTokensTag: number | null
|
||||||
|
maxTokensDescribe: number | null
|
||||||
|
maxTokensExtract: number | null
|
||||||
|
maxTokensTranslate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(startedAt: number): string {
|
||||||
|
const seconds = Math.floor((Date.now() - startedAt) / 1000)
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return `${m}m ${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AiTaggingPage() {
|
export default function AiTaggingPage() {
|
||||||
const [settings, setSettings] = useState<AiSettings>({ endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', enabled: false, preferredLanguage: 'English' })
|
const [settings, setSettings] = useState<AiSettings>({
|
||||||
|
endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
||||||
|
enabled: false, preferredLanguage: 'English',
|
||||||
|
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||||
|
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)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
@@ -24,12 +90,31 @@ export default function AiTaggingPage() {
|
|||||||
const [retagging, setRetagging] = useState(false)
|
const [retagging, setRetagging] = useState(false)
|
||||||
const [retagResult, setRetagResult] = useState<string | null>(null)
|
const [retagResult, setRetagResult] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
|
const [libraryOverrides, setLibraryOverrides] = useState<Record<string, LibraryOverride>>({})
|
||||||
|
const [expandedLibrary, setExpandedLibrary] = useState<string | null>(null)
|
||||||
|
const [librarySaving, setLibrarySaving] = useState<Record<string, boolean>>({})
|
||||||
|
const [librarySaveResult, setLibrarySaveResult] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||||
|
|
||||||
|
// Job queue state
|
||||||
|
const [jobQueue, setJobQueue] = useState<AiJob[]>([])
|
||||||
|
const [jobHistory, setJobHistory] = useState<AiJob[]>([])
|
||||||
|
const [historyExpanded, setHistoryExpanded] = useState(false)
|
||||||
|
const jobPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const fetchSettings = useCallback(async () => {
|
const fetchSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-settings')
|
const [settingsRes, librariesRes] = await Promise.all([
|
||||||
if (!res.ok) return
|
fetch('/api/ai-settings'),
|
||||||
const data: AiSettings = await res.json()
|
fetch('/api/libraries'),
|
||||||
setSettings(data)
|
])
|
||||||
|
if (settingsRes.ok) {
|
||||||
|
const data: AiSettings = await settingsRes.json()
|
||||||
|
setSettings(data)
|
||||||
|
}
|
||||||
|
if (librariesRes.ok) {
|
||||||
|
const data: Library[] = await librariesRes.json()
|
||||||
|
setLibraries(data)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,6 +126,100 @@ export default function AiTaggingPage() {
|
|||||||
fetchSettings()
|
fetchSettings()
|
||||||
}, [fetchSettings])
|
}, [fetchSettings])
|
||||||
|
|
||||||
|
// ─── Job queue polling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-jobs')
|
||||||
|
if (res.ok) {
|
||||||
|
const data: { queue: AiJob[]; history: AiJob[] } = await res.json()
|
||||||
|
setJobQueue(data.queue)
|
||||||
|
setJobHistory(data.history)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJobs()
|
||||||
|
}, [fetchJobs])
|
||||||
|
|
||||||
|
// Poll every 2s while there are active jobs
|
||||||
|
useEffect(() => {
|
||||||
|
const hasActive = jobQueue.length > 0
|
||||||
|
if (hasActive) {
|
||||||
|
jobPollRef.current = setInterval(fetchJobs, 2000)
|
||||||
|
} else {
|
||||||
|
if (jobPollRef.current) {
|
||||||
|
clearInterval(jobPollRef.current)
|
||||||
|
jobPollRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (jobPollRef.current) clearInterval(jobPollRef.current)
|
||||||
|
}
|
||||||
|
}, [jobQueue.length, fetchJobs])
|
||||||
|
|
||||||
|
const handleRetryJob = async (jobId: string) => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'retry', jobId }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelJob = async (jobId: string) => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'cancel', jobId }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelAll = async () => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'cancel-all' }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearHistory = async () => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'clear-history' }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLibraryOverrides = useCallback(async (libraryId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ai-settings/library/${libraryId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data: LibraryOverride = await res.json()
|
||||||
|
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleToggleLibrary = (libraryId: string) => {
|
||||||
|
if (expandedLibrary === libraryId) {
|
||||||
|
setExpandedLibrary(null)
|
||||||
|
} else {
|
||||||
|
setExpandedLibrary(libraryId)
|
||||||
|
if (!libraryOverrides[libraryId]) {
|
||||||
|
fetchLibraryOverrides(libraryId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
@@ -104,15 +283,218 @@ export default function AiTaggingPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveLibraryOverride = async (libraryId: string) => {
|
||||||
|
const overrides = libraryOverrides[libraryId]
|
||||||
|
if (!overrides) return
|
||||||
|
setLibrarySaving((prev) => ({ ...prev, [libraryId]: true }))
|
||||||
|
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: '' } }))
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ai-settings/library/${libraryId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(overrides),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
|
||||||
|
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: 'Saved.' } }))
|
||||||
|
setTimeout(() => setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: '' } })), 3000)
|
||||||
|
} else {
|
||||||
|
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: data.error ?? 'Failed to save.' } }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: 'Network error.' } }))
|
||||||
|
} finally {
|
||||||
|
setLibrarySaving((prev) => ({ ...prev, [libraryId]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string | number | null) => {
|
||||||
|
setLibraryOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
AI Tagging
|
AI Integrations
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Automatically tag media using a vision-capable LLM on your network.
|
Manage AI-powered tagging, descriptions, and text extraction.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* ─── Job Queue ─────────────────────────────────────────────────────── */}
|
||||||
|
<Section title="Job Queue">
|
||||||
|
{(() => {
|
||||||
|
const running = jobQueue.filter((j) => j.status === 'running')
|
||||||
|
const queued = jobQueue.filter((j) => j.status === 'queued')
|
||||||
|
if (running.length === 0 && queued.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No active jobs.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{running.length > 0 && <span>{running.length} running</span>}
|
||||||
|
{running.length > 0 && queued.length > 0 && ', '}
|
||||||
|
{queued.length > 0 && <span>{queued.length} queued</span>}
|
||||||
|
</p>
|
||||||
|
{queued.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelAll}
|
||||||
|
className="text-xs px-2 py-1 rounded transition-colors"
|
||||||
|
style={{ color: '#fca5a5', backgroundColor: '#7f1d1d33' }}
|
||||||
|
>
|
||||||
|
Cancel All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{running.map((job) => (
|
||||||
|
<div key={job.id} className="flex items-center gap-3 py-2">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full animate-pulse"
|
||||||
|
style={{ backgroundColor: '#16a34a33', color: '#4ade80' }}
|
||||||
|
>
|
||||||
|
Running
|
||||||
|
</span>
|
||||||
|
<span className="text-sm flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{job.itemTitle || job.itemKey}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.jobType}
|
||||||
|
</span>
|
||||||
|
{job.startedAt && (
|
||||||
|
<span className="text-xs tabular-nums" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{formatElapsed(job.startedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{queued.map((job) => (
|
||||||
|
<div key={job.id} className="flex items-center gap-3 py-2">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Queued
|
||||||
|
</span>
|
||||||
|
<span className="text-sm flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{job.itemTitle || job.itemKey}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.jobType}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCancelJob(job.id)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Job History ───────────────────────────────────────────────────── */}
|
||||||
|
<Section title="Job History">
|
||||||
|
{jobHistory.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No recent jobs.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHistoryExpanded((v) => !v)}
|
||||||
|
className="flex items-center gap-2 text-left"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs transition-transform inline-block"
|
||||||
|
style={{ transform: historyExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
>
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{jobHistory.length} recent job{jobHistory.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{historyExpanded && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{jobHistory.map((job) => (
|
||||||
|
<div key={job.id} className="flex items-center gap-3 py-2">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
backgroundColor: job.status === 'completed' ? '#14532d33' : '#7f1d1d33',
|
||||||
|
color: job.status === 'completed' ? '#4ade80' : '#fca5a5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{job.status === 'completed' ? 'Done' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm truncate block" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{job.itemTitle || job.itemKey}
|
||||||
|
</span>
|
||||||
|
{job.status === 'failed' && job.error && (
|
||||||
|
<span className="text-xs truncate block" style={{ color: '#fca5a5' }}>
|
||||||
|
{job.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.jobType}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.completedAt ? formatDate(job.completedAt) : ''}
|
||||||
|
</span>
|
||||||
|
{job.status === 'failed' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRetryJob(job.id)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded transition-colors whitespace-nowrap"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearHistory}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Connection">
|
<Section title="Connection">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingRows />
|
<LoadingRows />
|
||||||
@@ -175,6 +557,25 @@ 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"
|
||||||
@@ -192,6 +593,25 @@ 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"
|
||||||
@@ -209,6 +629,91 @@ 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"
|
||||||
@@ -226,6 +731,25 @@ 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
|
||||||
@@ -271,6 +795,29 @@ export default function AiTaggingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Max Retries">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
value={settings.maxRetries}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, maxRetries: Math.max(0, Math.min(10, parseInt(e.target.value) || 0)) }))
|
||||||
|
}
|
||||||
|
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)' }}>
|
||||||
|
Number of times to automatically retry a failed AI job before marking it as failed (0–10).
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<p
|
<p
|
||||||
className="text-sm rounded-lg px-3 py-2"
|
className="text-sm rounded-lg px-3 py-2"
|
||||||
@@ -334,6 +881,288 @@ export default function AiTaggingPage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Prompts">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingRows />
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSave} className="flex flex-col gap-5">
|
||||||
|
<Field label="Description Instructions">
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={settings.promptDescribe}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, promptDescribe: e.target.value }))}
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||||
|
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="Tagger Instructions">
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={settings.promptTagger}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, promptTagger: e.target.value }))}
|
||||||
|
placeholder="(blank by default)"
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||||
|
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 Instructions">
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={settings.promptExtract}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, promptExtract: e.target.value }))}
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||||
|
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="Translation Instructions">
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={settings.promptTranslate}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, promptTranslate: e.target.value }))}
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p
|
||||||
|
className="text-sm rounded-lg px-3 py-2"
|
||||||
|
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
{saveError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveSuccess && (
|
||||||
|
<p
|
||||||
|
className="text-sm rounded-lg px-3 py-2"
|
||||||
|
style={{ backgroundColor: '#14532d33', color: '#4ade80' }}
|
||||||
|
>
|
||||||
|
Settings saved.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!saving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Prompts'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Library Overrides">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingRows />
|
||||||
|
) : libraries.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{libraries.map((lib) => {
|
||||||
|
const isExpanded = expandedLibrary === lib.id
|
||||||
|
const overrides = libraryOverrides[lib.id] ?? null
|
||||||
|
const isSaving = librarySaving[lib.id] ?? false
|
||||||
|
const saveResult = librarySaveResult[lib.id]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={lib.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleLibrary(lib.id)}
|
||||||
|
className="w-full flex items-center justify-between py-3 text-left"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{lib.name}</span>
|
||||||
|
<span
|
||||||
|
className="text-xs transition-transform"
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pb-5 flex flex-col gap-5">
|
||||||
|
{overrides === null ? (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Leave any field blank to use the global default.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Models</p>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['modelTagging', 'Tagging Model'],
|
||||||
|
['modelDescribe', 'Description Model'],
|
||||||
|
['modelExtract', 'Text Extraction Model'],
|
||||||
|
['modelTranslate', 'Translation Model'],
|
||||||
|
] as [keyof LibraryOverride, string][]
|
||||||
|
).map(([field, label]) => (
|
||||||
|
<Field key={field} label={label}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={overrides[field] as string}
|
||||||
|
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]})` : ''}`}
|
||||||
|
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)')}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['promptDescribe', 'Description Instructions', settings.promptDescribe],
|
||||||
|
['promptTagger', 'Tagger Instructions', settings.promptTagger],
|
||||||
|
['promptExtract', 'Text Extraction Instructions', settings.promptExtract],
|
||||||
|
['promptTranslate', 'Translation Instructions', settings.promptTranslate],
|
||||||
|
] as [keyof LibraryOverride, string, string][]
|
||||||
|
).map(([field, label, globalValue]) => (
|
||||||
|
<Field key={field} label={label}>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={overrides[field] as string}
|
||||||
|
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'}
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{saveResult?.message && (
|
||||||
|
<p
|
||||||
|
className="text-sm rounded-lg px-3 py-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: saveResult.ok ? '#14532d33' : '#7f1d1d33',
|
||||||
|
color: saveResult.ok ? '#4ade80' : '#fca5a5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saveResult.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSaveLibraryOverride(lib.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSaving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : `Save ${lib.name} Overrides`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Re-tag">
|
<Section title="Re-tag">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
@@ -365,6 +1194,14 @@ export default function AiTaggingPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyOverride(): LibraryOverride {
|
||||||
|
return {
|
||||||
|
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
||||||
|
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||||
|
maxTokensTag: null, maxTokensDescribe: null, maxTokensExtract: null, maxTokensTranslate: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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: '🎬',
|
||||||
@@ -12,6 +13,7 @@ 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',
|
||||||
@@ -20,7 +22,7 @@ const TYPE_LABELS: Record<LibraryType, string> = {
|
|||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ManagePage() {
|
function ManagePage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -105,8 +107,12 @@ 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) {
|
||||||
@@ -121,6 +127,26 @@ 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)
|
||||||
@@ -207,6 +233,57 @@ 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}
|
||||||
@@ -253,6 +330,216 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -334,6 +621,7 @@ 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>
|
||||||
@@ -415,3 +703,57 @@ 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
|
||||||
|
|||||||
865
src/app/manage/tags/mappings/[id]/page.tsx
Normal file
865
src/app/manage/tags/mappings/[id]/page.tsx
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
'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 } from '@/types'
|
import type { Tag, TagCategory, Library, ImportedTag } from '@/types'
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,6 +62,8 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,11 +85,13 @@ 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)}`, {
|
||||||
@@ -96,8 +100,35 @@ 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.')
|
||||||
@@ -156,7 +187,7 @@ function CategoryBlock({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditing(false); setEditName(category.name); setError(null) }}
|
onClick={() => { setEditing(false); setEditName(category.name); setError(null); setMergeConflict(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)' }}
|
||||||
>
|
>
|
||||||
@@ -228,6 +259,32 @@ 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) => (
|
||||||
@@ -480,6 +537,117 @@ 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,32 +216,39 @@ 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 [permitted, setPermitted] = useState<string[]>([])
|
const [levels, setLevels] = useState<Record<string, AccessLevel>>({})
|
||||||
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: { libraryIds: string[] }) => {
|
.then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => {
|
||||||
setPermitted(data.libraryIds)
|
const map: Record<string, AccessLevel> = {}
|
||||||
|
for (const p of data.permissions) {
|
||||||
|
map[p.libraryId] = p.accessLevel
|
||||||
|
}
|
||||||
|
setLevels(map)
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
})
|
})
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
const toggle = (libraryId: string) => {
|
const setLevel = (libraryId: string, level: AccessLevel) => {
|
||||||
setPermitted((prev) =>
|
setLevels((prev) => ({ ...prev, [libraryId]: level }))
|
||||||
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({ libraryIds: permitted }),
|
body: JSON.stringify({ permissions }),
|
||||||
})
|
})
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -265,23 +272,40 @@ 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-1.5">
|
<div className="space-y-2">
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => {
|
||||||
<label key={lib.id} className="flex items-center gap-2 cursor-pointer">
|
const current = levels[lib.id] ?? 'none'
|
||||||
<input
|
return (
|
||||||
type="checkbox"
|
<div key={lib.id} className="flex items-center justify-between gap-3">
|
||||||
checked={permitted.includes(lib.id)}
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
onChange={() => toggle(lib.id)}
|
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
className="rounded"
|
{lib.name}
|
||||||
/>
|
</span>
|
||||||
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{lib.name}
|
({lib.type})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
</div>
|
||||||
({lib.type})
|
<div
|
||||||
</span>
|
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
|
||||||
</label>
|
style={{ border: '1px solid var(--border)' }}
|
||||||
))}
|
>
|
||||||
|
{(['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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Props {
|
|||||||
items: DoomScrollItem[]
|
items: DoomScrollItem[]
|
||||||
videoContext?: 'mixed' | 'movies' | 'tv'
|
videoContext?: 'mixed' | 'movies' | 'tv'
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onViewInLibrary?: (item: DoomScrollItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_CAP = 100
|
const HISTORY_CAP = 100
|
||||||
@@ -26,7 +27,7 @@ function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): D
|
|||||||
return pool[Math.floor(Math.random() * pool.length)]
|
return pool[Math.floor(Math.random() * pool.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DoomScrollView({ items, videoContext = 'mixed', onClose }: Props) {
|
export default function DoomScrollView({ items, videoContext = 'mixed', onClose, onViewInLibrary }: Props) {
|
||||||
const settings = useUserSettings()
|
const settings = useUserSettings()
|
||||||
const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted
|
const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted
|
||||||
|
|
||||||
@@ -40,7 +41,32 @@ 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
|
||||||
|
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 [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -48,6 +74,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
const isVideo = current?.mediaType === 'video'
|
const isVideo = current?.mediaType === 'video'
|
||||||
const backCount = history.length - 1 - historyIndex
|
const backCount = history.length - 1 - historyIndex
|
||||||
|
|
||||||
|
// Derived: what text to display in the overlay
|
||||||
|
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
const goNext = useCallback(() => {
|
||||||
if (items.length === 0) return
|
if (items.length === 0) return
|
||||||
setHistoryIndex((idx) => {
|
setHistoryIndex((idx) => {
|
||||||
@@ -114,11 +143,67 @@ 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
|
||||||
|
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)
|
||||||
|
setEditedExtractedText('')
|
||||||
|
setTranslatedText(null)
|
||||||
|
setShowTextOverlay(false)
|
||||||
|
setShowOriginal(false)
|
||||||
|
setExtracting(false)
|
||||||
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
|
setRetranslating(false)
|
||||||
|
setTranslatePending(false)
|
||||||
|
setUserRatingState(null)
|
||||||
|
setRatingHover(null)
|
||||||
|
if (!current?.itemKey) return
|
||||||
|
const key = current.itemKey
|
||||||
|
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||||
|
setExtractedText(data.extractedText)
|
||||||
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { userRating: number | null }) => {
|
||||||
|
setUserRatingState(data.userRating)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [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 }
|
||||||
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') }
|
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') }
|
||||||
if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') }
|
if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') }
|
||||||
|
if (e.key === 't' || e.key === 'T') {
|
||||||
|
if (extractedText) setShowTextOverlay((v) => !v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -147,7 +232,146 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
document.removeEventListener('touchend', handleTouchEnd)
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [navigate, onClose])
|
}, [navigate, onClose, extractedText])
|
||||||
|
|
||||||
|
// ── Polling helper ──────────────────────────────────────────────────────────
|
||||||
|
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => {
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
const newText: string | null = result.extractedText || null
|
||||||
|
const newTranslated: string | null = result.translatedText || null
|
||||||
|
setExtractedText(newText)
|
||||||
|
setEditedExtractedText(newText ?? '')
|
||||||
|
setTranslatedText(newTranslated)
|
||||||
|
if (newText) setShowTextOverlay(true)
|
||||||
|
} catch (err) {
|
||||||
|
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
|
||||||
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
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' }}>
|
||||||
@@ -219,8 +443,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
loop={!autoPlayEnabled}
|
loop={!autoPlayEnabled}
|
||||||
muted={localMuted}
|
muted={localMuted}
|
||||||
playsInline
|
playsInline
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain cursor-pointer"
|
||||||
style={{ backgroundColor: '#000' }}
|
style={{ backgroundColor: '#000' }}
|
||||||
|
onClick={() => setIsPaused((v) => !v)}
|
||||||
/>
|
/>
|
||||||
) : current?.mediaType === 'image' ? (
|
) : current?.mediaType === 'image' ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -233,32 +458,324 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar: mute | filename | play-pause */}
|
{/* 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 */}
|
||||||
|
{showTextOverlay && displayText && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom bar: [mute + tools] | 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="w-9 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLocalMuted((v) => !v)}
|
onClick={() => setLocalMuted((v) => !v)}
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-base transition-opacity hover:opacity-100 opacity-70"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||||
aria-label={localMuted ? 'Unmute' : 'Mute'}
|
aria-label={localMuted ? 'Unmute' : 'Mute'}
|
||||||
>
|
>
|
||||||
{localMuted ? '🔇' : '🔊'}
|
{localMuted ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||||
|
<line x1="23" y1="9" x2="17" y2="15"/>
|
||||||
|
<line x1="17" y1="9" x2="23" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</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>
|
</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}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-9 flex-shrink-0 flex justify-end">
|
<div className="flex-shrink-0 flex items-center gap-1">
|
||||||
{isVideo && (
|
{extractedText ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsPaused((v) => !v)}
|
onClick={() => setShowTextOverlay((v) => !v)}
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-sm transition-opacity hover:opacity-100 opacity-70"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
style={{
|
||||||
aria-label={isPaused ? 'Play' : 'Pause'}
|
backgroundColor: showTextOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||||||
>
|
>
|
||||||
{isPaused ? '▶' : '⏸'}
|
<svg width="16" height="16" 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="12" x2="15" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => callExtract('tesseract')}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
backgroundColor: extractPending
|
||||||
|
? 'var(--accent)'
|
||||||
|
: extractError
|
||||||
|
? 'rgba(127,29,29,0.8)'
|
||||||
|
: 'rgba(0,0,0,0.5)',
|
||||||
|
color: extractError ? '#fca5a5' : '#fff',
|
||||||
|
}}
|
||||||
|
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
||||||
|
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
||||||
|
>
|
||||||
|
{extracting || extractPending ? (
|
||||||
|
<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">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{onViewInLibrary && current?.itemKey && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onViewInLibrary(current) }}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||||
|
aria-label="View in library"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Tag, TagCategory } from '@/types'
|
import type { Tag, TagCategory, RatingOperator } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
@@ -11,9 +11,24 @@ 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({ assignments, search, onSearchChange, selectedTagIds, onTagToggle, refreshKey }: Props) {
|
export default function FilterPanel({
|
||||||
|
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)
|
||||||
@@ -53,6 +68,59 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
@@ -62,7 +130,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
|||||||
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">
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||||
{[50, 65, 42].map((w) => (
|
{[50, 65, 42].map((w) => (
|
||||||
<div
|
<div
|
||||||
key={w}
|
key={w}
|
||||||
@@ -84,7 +152,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
|||||||
<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">
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||||
{catTags.map((tag) => {
|
{catTags.map((tag) => {
|
||||||
const active = selectedTagIds.has(tag.id)
|
const active = selectedTagIds.has(tag.id)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const TABS = [
|
|||||||
{ href: '/manage/tags', label: 'Tags' },
|
{ href: '/manage/tags', label: 'Tags' },
|
||||||
{ href: '/manage/users', label: 'Users' },
|
{ href: '/manage/users', label: 'Users' },
|
||||||
{ href: '/manage/scanning', label: 'Scanning' },
|
{ href: '/manage/scanning', label: 'Scanning' },
|
||||||
{ href: '/manage/ai-tagging', label: 'AI Tagging' },
|
{ href: '/manage/ai-tagging', label: 'AI Integrations' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function ManageSubNav() {
|
export default function ManageSubNav() {
|
||||||
|
|||||||
332
src/components/comics/ComicIssueView.tsx
Normal file
332
src/components/comics/ComicIssueView.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
'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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
233
src/components/comics/ComicSeriesView.tsx
Normal file
233
src/components/comics/ComicSeriesView.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
561
src/components/comics/ComicsView.tsx
Normal file
561
src/components/comics/ComicsView.tsx
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
'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,7 +2,8 @@
|
|||||||
|
|
||||||
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 TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
|
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'
|
||||||
@@ -29,12 +30,15 @@ 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, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
|
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: 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)
|
||||||
@@ -46,6 +50,9 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
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 }>>([])
|
||||||
@@ -54,6 +61,8 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
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)}`)
|
||||||
@@ -65,6 +74,14 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
|
|
||||||
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
|
||||||
@@ -106,11 +123,14 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +140,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length])
|
}, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -153,306 +173,372 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
<div
|
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||||
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
{/* ── Left pane — relative container for floating controls ── */}
|
||||||
{editingImages ? (
|
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
||||||
<ImageEditor
|
{/* Scrollable card area */}
|
||||||
game={game}
|
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
||||||
libraryId={libraryId}
|
<div
|
||||||
onBack={() => setEditingImages(false)}
|
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
onUploaded={onCoverUploaded}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
/>
|
onClick={(e) => e.stopPropagation()}
|
||||||
) : (
|
>
|
||||||
<>
|
{editingImages ? (
|
||||||
{/* Close button */}
|
<ImageEditor
|
||||||
|
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="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
className={smallBtn}
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hero image */}
|
{/* Prev / Next */}
|
||||||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
{onPrev && (
|
||||||
{heroImage ? (
|
<button
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
|
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' }}
|
||||||
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
|
aria-label="Previous"
|
||||||
)}
|
>
|
||||||
</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>
|
||||||
|
|
||||||
{/* Info */}
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
<div className="p-5">
|
{showTagPanel && (
|
||||||
{/* Title row with kebab menu */}
|
<MediaTagPanel
|
||||||
<div className="flex items-center gap-2 mb-4">
|
itemKey={game.item_key!}
|
||||||
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
onHide={() => setShowTagPanel(false)}
|
||||||
{game.title}
|
onClose={onClose}
|
||||||
</h2>
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
|
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>
|
||||||
|
|
||||||
{/* Lightbox */}
|
{/* Screenshot lightbox (z-60, sits above the modal) */}
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 flex items-center justify-center"
|
className="fixed inset-0 flex items-center justify-center"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||||
import type { Game, GamePlatform, GameSeries } from '@/types'
|
import type { Game, GamePlatform, GameSeries, RatingOperator } 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'
|
||||||
|
|
||||||
@@ -58,9 +59,10 @@ function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GamesView({ libraryId }: Props) {
|
export default function GamesView({ libraryId, readOnly }: 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)
|
||||||
@@ -71,8 +73,14 @@ export default function GamesView({ libraryId }: 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(true)
|
const [showFilters, setShowFilters] = useState(
|
||||||
|
() => 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) => {
|
||||||
@@ -124,29 +132,72 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
? selectedSeries.games
|
? selectedSeries.games
|
||||||
: items
|
: items
|
||||||
|
|
||||||
const filtered = visibleItems.filter((item) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
|
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) {
|
||||||
const searchMatch = !search ||
|
if (debouncedSearch) {
|
||||||
item.title.toLowerCase().includes(search.toLowerCase()) ||
|
const q = debouncedSearch.toLowerCase()
|
||||||
item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase()))
|
const searchMatch =
|
||||||
if (!searchMatch) return false
|
item.title.toLowerCase().includes(q) ||
|
||||||
|
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) {
|
||||||
return item.games.some((g) => {
|
if (!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
|
||||||
}
|
}
|
||||||
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
// Standalone Game
|
||||||
|
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
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
const filteredGames: Game[] = filtered.flatMap((item) =>
|
||||||
|
'games' in item ? item.games : [item as Game]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -175,6 +226,9 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -220,7 +274,7 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
<GameCard
|
<GameCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
game={item}
|
game={item}
|
||||||
onClick={() => setSelected(item)}
|
onClick={() => { setSelected(item); setSelectedGameIndex(filteredGames.indexOf(item)) }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -231,11 +285,19 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
<GameDetailModal
|
<GameDetailModal
|
||||||
game={selected}
|
game={selected}
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
onClose={() => setSelected(null)}
|
readOnly={readOnly}
|
||||||
|
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()
|
||||||
}}
|
}}
|
||||||
@@ -289,6 +351,7 @@ 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
|
||||||
@@ -305,9 +368,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)' }}>
|
||||||
{series.coverUrl ? (
|
{resolvedCover ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={series.coverUrl} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
|
<img src={resolvedCover} 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 } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
@@ -12,49 +12,144 @@ 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 }: Props) {
|
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTags, setShowTags] = useState(
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
)
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
|
||||||
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 [savingText, setSavingText] = useState(false)
|
||||||
|
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
|
||||||
|
const [showTextOverlay, setShowTextOverlay] = 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)
|
||||||
|
|
||||||
|
// Derived: what text to display in the overlay
|
||||||
|
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||||
|
|
||||||
// Fetch existing AI fields on mount / item change
|
// Fetch existing AI fields on mount / item change
|
||||||
useEffect(() => {
|
const fetchAiFields = useCallback(() => {
|
||||||
if (!itemKey) return
|
if (!itemKey) return Promise.resolve()
|
||||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
|
||||||
setExtractedText(data.extractedText)
|
setExtractedText(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])
|
||||||
@@ -63,266 +158,94 @@ 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 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
|
||||||
<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-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
|
||||||
color: showTags ? '#fff' : 'var(--text-primary)',
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
}}
|
|
||||||
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-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"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTags ? (
|
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-full">
|
<div className="relative flex-1 min-h-0 min-w-0">
|
||||||
{/* Image */}
|
|
||||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={name}
|
|
||||||
className="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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Tag panel */}
|
|
||||||
<div
|
|
||||||
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} />
|
|
||||||
|
|
||||||
{/* Text extraction section — only for images */}
|
|
||||||
{isImage && (
|
|
||||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Text Extraction
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
setExtracting(true)
|
|
||||||
setExtractError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
|
||||||
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 ?? 'Failed to extract text')
|
|
||||||
}
|
|
||||||
const result = await res.json()
|
|
||||||
setExtractedText(result.extractedText || null)
|
|
||||||
setTranslatedText(result.translatedText || null)
|
|
||||||
} catch (err) {
|
|
||||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
|
||||||
} finally {
|
|
||||||
setExtracting(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={extracting}
|
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!extracting) {
|
|
||||||
;(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)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{extractError && (
|
|
||||||
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{extractedText && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Extracted Text
|
|
||||||
</p>
|
|
||||||
<pre
|
|
||||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
|
||||||
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
{extractedText}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{translatedText && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Translation
|
|
||||||
</p>
|
|
||||||
<pre
|
|
||||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
|
||||||
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
{translatedText}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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 }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
|
||||||
}
|
|
||||||
const result = await res.json()
|
|
||||||
setTranslatedText(result.translatedText || null)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setRetranslating(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={retranslating}
|
|
||||||
className="self-start 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 (!retranslating) {
|
|
||||||
;(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)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="max-w-full max-h-full object-contain rounded-lg"
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Prev / Next */}
|
||||||
{onPrev && (
|
{onPrev && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
@@ -343,8 +266,372 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
›
|
›
|
||||||
</button>
|
</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
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||||||
|
className={`absolute bottom-4 right-4 ${smallBtn}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||||||
|
title="Display text"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" 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="12" x2="15" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
{/* Description section */}
|
||||||
|
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Description
|
||||||
|
</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>
|
||||||
|
<textarea
|
||||||
|
value={editedDescription}
|
||||||
|
onChange={(e) => setEditedDescription(e.target.value)}
|
||||||
|
placeholder="No description yet…"
|
||||||
|
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
minHeight: '3.5rem',
|
||||||
|
maxHeight: '8rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{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 */}
|
||||||
|
{isImage && (
|
||||||
|
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Text Extraction
|
||||||
|
</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
|
||||||
|
onClick={() => callExtract('tesseract')}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!extracting && !extractPending) {
|
||||||
|
;(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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
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" style={{ color: '#f87171' }}>{extractError}</p>}
|
||||||
|
|
||||||
|
{extractedText && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Extracted Text
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={editedExtractedText}
|
||||||
|
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||||
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
minHeight: '4rem',
|
||||||
|
maxHeight: '10rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editedExtractedText !== extractedText && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingText(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/ai-tagging/fields', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||||||
|
})
|
||||||
|
setExtractedText(editedExtractedText)
|
||||||
|
} finally {
|
||||||
|
setSavingText(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingText}
|
||||||
|
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{savingText ? '⟳ Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{translatedText && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Translation
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
||||||
|
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{translatedText}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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)
|
||||||
|
setTranslatePending(false)
|
||||||
|
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.status === 202) {
|
||||||
|
setTranslatePending(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 translate')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRetranslating(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={retranslating || translatePending}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!retranslating && !translatePending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!translatePending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MediaTagPanel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||||
import type { DirectoryListing, FileEntry } from '@/types'
|
import type { DirectoryListing, FileEntry, RatingOperator } 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'
|
||||||
@@ -11,7 +12,9 @@ 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 =
|
||||||
@@ -21,23 +24,31 @@ type ModalState =
|
|||||||
|
|
||||||
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
||||||
|
|
||||||
export default function MixedView({ libraryId, initialPath }: Props) {
|
export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: 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(true)
|
const [showFilters, setShowFilters] = useState(
|
||||||
|
() => 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)
|
||||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||||
|
const [doomScrollEntries, setDoomScrollEntries] = useState<FileEntry[]>([])
|
||||||
|
const [doomScrollEntriesLoading, setDoomScrollEntriesLoading] = useState(false)
|
||||||
|
const [doomScrollEntriesLoaded, setDoomScrollEntriesLoaded] = useState(false)
|
||||||
|
const [pendingOpen, setPendingOpen] = useState<string | null>(null)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -71,6 +82,17 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
loadPath(initialPath)
|
loadPath(initialPath)
|
||||||
}, [loadPath, initialPath])
|
}, [loadPath, initialPath])
|
||||||
|
|
||||||
|
// Invalidate doom scroll entry cache when the user navigates to a different directory
|
||||||
|
useEffect(() => {
|
||||||
|
setDoomScrollEntries([])
|
||||||
|
setDoomScrollEntriesLoaded(false)
|
||||||
|
setDoomScrollEntriesLoading(false)
|
||||||
|
setDoomScrollLoading(false)
|
||||||
|
}, [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())
|
||||||
@@ -80,7 +102,29 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
useEffect(() => {
|
||||||
|
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
|
||||||
@@ -95,6 +139,21 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
.finally(() => setRecursiveLoading(false))
|
.finally(() => setRecursiveLoading(false))
|
||||||
}, [libraryId, recursiveLoaded, recursiveLoading])
|
}, [libraryId, recursiveLoaded, recursiveLoading])
|
||||||
|
|
||||||
|
const fetchDoomScrollEntries = useCallback(() => {
|
||||||
|
if (doomScrollEntriesLoaded || doomScrollEntriesLoading) return
|
||||||
|
setDoomScrollEntriesLoading(true)
|
||||||
|
fetch(
|
||||||
|
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(currentPath)}&recursive=true`
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: DirectoryListing) => {
|
||||||
|
setDoomScrollEntries(data.entries)
|
||||||
|
setDoomScrollEntriesLoaded(true)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setDoomScrollEntriesLoading(false))
|
||||||
|
}, [libraryId, currentPath, doomScrollEntriesLoaded, doomScrollEntriesLoading])
|
||||||
|
|
||||||
// Fetch the full recursive listing the first time any filter becomes active
|
// Fetch the full recursive listing the first time any filter becomes active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filtersActive) return
|
if (!filtersActive) return
|
||||||
@@ -110,14 +169,31 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
|
|
||||||
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||||
|
|
||||||
const filteredEntries = sourceEntries.filter((entry) => {
|
const filteredEntries = useMemo(() => sourceEntries.filter((entry) => {
|
||||||
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
if (debouncedSearch) {
|
||||||
|
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'
|
||||||
@@ -182,27 +258,65 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
fetchRecursive()
|
fetchRecursive()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recursiveLoaded) {
|
// No filters: scope to current directory
|
||||||
|
if (doomScrollEntriesLoaded) {
|
||||||
setDoomScrollActive(true)
|
setDoomScrollActive(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDoomScrollLoading(true)
|
setDoomScrollLoading(true)
|
||||||
fetchRecursive()
|
fetchDoomScrollEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate doom scroll once the recursive listing finishes loading (when triggered by button)
|
// Activate doom scroll once the appropriate listing finishes loading (when triggered by button)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) {
|
if (!doomScrollLoading) return
|
||||||
|
const filtersDone = filtersActive && !recursiveLoading && recursiveLoaded
|
||||||
|
const noFiltersDone = !filtersActive && !doomScrollEntriesLoading && doomScrollEntriesLoaded
|
||||||
|
if (filtersDone || noFiltersDone) {
|
||||||
setDoomScrollLoading(false)
|
setDoomScrollLoading(false)
|
||||||
setDoomScrollActive(true)
|
setDoomScrollActive(true)
|
||||||
}
|
}
|
||||||
}, [doomScrollLoading, recursiveLoading, recursiveLoaded])
|
}, [
|
||||||
|
doomScrollLoading, filtersActive,
|
||||||
|
recursiveLoading, recursiveLoaded,
|
||||||
|
doomScrollEntriesLoading, doomScrollEntriesLoaded,
|
||||||
|
])
|
||||||
|
|
||||||
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
|
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
|
||||||
// When no filters, doom scroll uses the full recursiveEntries.
|
// When no filters, doom scroll uses files recursively under the current directory.
|
||||||
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries)
|
// In both cases entries come from recursive API calls so entry.name is the full relative path.
|
||||||
|
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : doomScrollEntries)
|
||||||
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
|
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
|
||||||
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))
|
.map((e) => ({
|
||||||
|
url: e.url!,
|
||||||
|
name: e.name,
|
||||||
|
mediaType: e.mediaType as 'video' | 'image',
|
||||||
|
itemKey: `${libraryId}:mixed_file:${encodeURIComponent(e.name)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleViewInLibrary = useCallback((item: DoomScrollItem) => {
|
||||||
|
if (!item.itemKey) return
|
||||||
|
const rel = decodeURIComponent(item.itemKey.split(':mixed_file:')[1])
|
||||||
|
const parts = rel.split('/')
|
||||||
|
parts.pop()
|
||||||
|
const dir = parts.join('/')
|
||||||
|
setDoomScrollActive(false)
|
||||||
|
setPendingOpen(rel)
|
||||||
|
loadPath(dir)
|
||||||
|
}, [loadPath])
|
||||||
|
|
||||||
|
// Auto-open a file after navigating to its directory (from "view in library")
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingOpen || !listing) return
|
||||||
|
const filename = pendingOpen.split('/').pop()!
|
||||||
|
const entry = listing.entries.find((e) => e.name === filename && e.type === 'file')
|
||||||
|
if (!entry) return
|
||||||
|
setPendingOpen(null)
|
||||||
|
const idx = mediaEntries.indexOf(entry)
|
||||||
|
openMediaEntry(entry, idx >= 0 ? idx : 0)
|
||||||
|
// openMediaEntry is defined inline and depends on stable state; listing/pendingOpen are the real triggers
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [listing, pendingOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -211,6 +325,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
items={doomScrollItems}
|
items={doomScrollItems}
|
||||||
videoContext="mixed"
|
videoContext="mixed"
|
||||||
onClose={() => setDoomScrollActive(false)}
|
onClose={() => setDoomScrollActive(false)}
|
||||||
|
onViewInLibrary={handleViewInLibrary}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -253,18 +368,29 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
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)' }}
|
||||||
>
|
>
|
||||||
Root
|
{libraryName}
|
||||||
</button>
|
</button>
|
||||||
{breadcrumbs.map((segment, i) => {
|
{breadcrumbs.map((segment, i) => {
|
||||||
const isLast = i === breadcrumbs.length - 1
|
const isLast = i === breadcrumbs.length - 1
|
||||||
@@ -321,6 +447,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
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', {
|
||||||
@@ -335,7 +463,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
fetchAssignments()
|
fetchAssignments()
|
||||||
setFilterRefreshKey((k) => k + 1)
|
setFilterRefreshKey((k) => k + 1)
|
||||||
}}
|
}}
|
||||||
onExtractText={async (e) => {
|
onExtractText={async (e, ocrLanguages) => {
|
||||||
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)
|
||||||
@@ -354,7 +482,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
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 }),
|
body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
@@ -387,6 +515,31 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
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' })
|
||||||
@@ -427,10 +580,13 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
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)}
|
onClose={() => { setModal(null); setModalShowTags(false) }}
|
||||||
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}
|
||||||
onAiTag={modal.itemKey ? async () => {
|
showTags={modalShowTags}
|
||||||
|
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' },
|
||||||
@@ -451,10 +607,13 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
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)}
|
onClose={() => { setModal(null); setModalShowTags(false) }}
|
||||||
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}
|
||||||
onAiTag={async () => {
|
showTags={modalShowTags}
|
||||||
|
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' },
|
||||||
@@ -516,7 +675,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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> }) {
|
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 }) {
|
||||||
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'
|
||||||
@@ -535,6 +694,10 @@ 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
|
||||||
@@ -649,7 +812,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'))) && (
|
{(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)) && (
|
||||||
<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) }}
|
||||||
@@ -724,16 +887,21 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
📝 Describe Folder
|
📝 Describe Folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExtractText && entry.mediaType === 'image' && (
|
{onExtractText && entry.mediaType === 'image' && !showOcrPrompt && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setMenuOpen(false)
|
if (ocrMode && ocrMode !== 'llm') {
|
||||||
setTextExtracting(true)
|
setOcrLanguageInput('')
|
||||||
setTextExtractError(null)
|
setShowOcrPrompt(true)
|
||||||
onExtractText(entry)
|
} else {
|
||||||
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
setMenuOpen(false)
|
||||||
.finally(() => setTextExtracting(false))
|
setTextExtracting(true)
|
||||||
|
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"
|
||||||
@@ -744,6 +912,57 @@ 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) => {
|
||||||
@@ -764,6 +983,26 @@ 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) => {
|
||||||
@@ -863,6 +1102,28 @@ 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 TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
import { useUserSettings } from '@/hooks/useUserSettings'
|
import { useUserSettings } from '@/hooks/useUserSettings'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,20 +14,21 @@ 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' }: Props) {
|
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: 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 [showTags, setShowTags] = useState(
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
)
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -47,93 +48,58 @@ 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 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}>
|
||||||
<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>
|
|
||||||
|
|
||||||
{showTags ? (
|
{/* ── Video column ── */}
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
|
<div className="flex flex-col flex-1 min-h-0 min-w-0 relative">
|
||||||
{/* Video */}
|
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
|
{/* Toolbar — scoped to this column's width */}
|
||||||
|
<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}
|
||||||
@@ -141,60 +107,18 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
loop={loop}
|
loop={loop}
|
||||||
className="w-full h-full object-contain rounded-lg"
|
playsInline
|
||||||
|
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 */}
|
|
||||||
<div
|
{/* Prev/Next — positioned relative to the full column height (incl. toolbar)
|
||||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
so they align with ImageLightbox's buttons which span the full viewport */}
|
||||||
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 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 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' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
aria-label="Previous"
|
aria-label="Previous"
|
||||||
>
|
>
|
||||||
@@ -204,7 +128,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 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 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' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
>
|
>
|
||||||
@@ -212,7 +136,19 @@ 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,7 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie } from '@/types'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
|
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,9 +15,10 @@ 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 }: Props) {
|
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: 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)
|
||||||
@@ -32,15 +34,22 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +59,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, menuOpen, confirming, editing, warnRefresh, renaming])
|
}, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,7 +141,6 @@ 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)
|
||||||
@@ -187,339 +195,387 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
<div
|
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||||
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Prev / Next buttons on the detail card */}
|
{/* ── Left pane — relative container for floating controls ── */}
|
||||||
{onPrev && (
|
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
||||||
<button
|
{/* Scrollable card area */}
|
||||||
onClick={onPrev}
|
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
||||||
className="absolute top-3 left-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
<div
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
onClick={(e) => e.stopPropagation()}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{/* Title row with kebab menu */}
|
{/* Title row with kebab menu */}
|
||||||
<div className="flex items-start gap-2 mb-1">
|
<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)' }}>
|
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
||||||
{movie.title}
|
{movie.title}
|
||||||
</h2>
|
</h2>
|
||||||
{movie.year && (
|
{movie.year && (
|
||||||
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{movie.year}
|
{movie.year}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Kebab menu */}
|
{/* Kebab menu */}
|
||||||
<div className="relative flex-shrink-0" ref={menuRef}>
|
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
>
|
>
|
||||||
⋮
|
⋮
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{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
|
<div
|
||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
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
|
<button
|
||||||
onClick={handleRefreshMetadata}
|
onClick={() => setWarnRefresh(false)}
|
||||||
disabled={refreshing}
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
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-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
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'}
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleStartEditing}
|
onClick={doRefreshMetadata}
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
style={{ color: 'var(--text-primary)' }}
|
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
||||||
>
|
>
|
||||||
Edit metadata
|
Overwrite
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rename inline input */}
|
{/* Confirmation banner */}
|
||||||
{renaming && (
|
{confirming && (
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
<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">
|
<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
|
<button
|
||||||
onClick={() => setRenaming(false)}
|
onClick={() => setPlaying(true)}
|
||||||
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium 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' }}
|
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)')}
|
||||||
>
|
>
|
||||||
{renameSaving ? '…' : 'Rename'}
|
<span>▶</span>
|
||||||
|
Play
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<a
|
||||||
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
href={videoUrl}
|
||||||
</div>
|
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)' }}
|
||||||
{editing ? (
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||||
<div className="flex flex-col gap-3 mb-4">
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
<div>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
|
title="Download"
|
||||||
<input
|
aria-label="Download"
|
||||||
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>
|
</a>
|
||||||
<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>
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* 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 ── */}
|
||||||
|
{showTagPanel && (
|
||||||
|
<MediaTagPanel
|
||||||
|
itemKey={movie.item_key!}
|
||||||
|
onHide={() => setShowTagPanel(false)}
|
||||||
|
onClose={onClose}
|
||||||
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie, RatingOperator } 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 }: Props) {
|
export default function MoviesView({ libraryId, readOnly }: 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)
|
||||||
@@ -19,8 +21,13 @@ export default function MoviesView({ libraryId }: 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(true)
|
const [showFilters, setShowFilters] = useState(
|
||||||
|
() => 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[]>([])
|
||||||
|
|
||||||
@@ -55,14 +62,34 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
const filtered = movies.filter((movie) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
|
if (value === ratingValue && operator === ratingOperator) {
|
||||||
|
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
|
||||||
|
|
||||||
@@ -71,7 +98,7 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
const handleDoomScroll = () => {
|
const handleDoomScroll = () => {
|
||||||
// Use filtered movies — respects any active search/tag filters automatically
|
// Use filtered movies — respects any active search/tag filters automatically
|
||||||
@@ -132,6 +159,9 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -201,6 +231,7 @@ export default function MoviesView({ libraryId }: 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}
|
||||||
|
|||||||
73
src/components/tags/AssignedTagBadges.tsx
Normal file
73
src/components/tags/AssignedTagBadges.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
src/components/tags/MediaTagPanel.tsx
Normal file
203
src/components/tags/MediaTagPanel.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'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,6 +8,8 @@ interface Props {
|
|||||||
itemKey: string
|
itemKey: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
refreshKey?: number
|
refreshKey?: number
|
||||||
|
hideDescription?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AllTags {
|
interface AllTags {
|
||||||
@@ -15,7 +17,7 @@ interface AllTags {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) {
|
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) {
|
||||||
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
||||||
tags: [],
|
tags: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
@@ -192,6 +194,11 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
|||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
|
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
|
||||||
}
|
}
|
||||||
|
if (res.status === 202) {
|
||||||
|
setDescError('Queued — check AI Integrations for progress')
|
||||||
|
setTimeout(() => setDescError(null), 4000)
|
||||||
|
return
|
||||||
|
}
|
||||||
const { description } = await res.json()
|
const { description } = await res.json()
|
||||||
setAiDescription(description)
|
setAiDescription(description)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -205,37 +212,39 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* AI description */}
|
{/* AI description */}
|
||||||
<div className="flex flex-col gap-1">
|
{!hideDescription && (
|
||||||
{aiDescription && (
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
{aiDescription && (
|
||||||
{aiDescription}
|
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
||||||
</p>
|
{aiDescription}
|
||||||
)}
|
</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">
|
||||||
@@ -269,23 +278,25 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
|||||||
style={{ backgroundColor: 'var(--surface-hover)' }}
|
style={{ backgroundColor: 'var(--surface-hover)' }}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
<button
|
{!readOnly && (
|
||||||
onClick={() => toggleTag(tag)}
|
<button
|
||||||
className="ml-0.5 leading-none transition-colors"
|
onClick={() => toggleTag(tag)}
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
className="ml-0.5 leading-none transition-colors"
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
aria-label={`Remove tag ${tag.name}`}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
>
|
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={() => toggleTag(tag)} />
|
<TagBadge key={tag.id} tag={tag} onRemove={readOnly ? undefined : () => toggleTag(tag)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -294,13 +305,17 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tag picker grouped by category */}
|
{/* Tag picker grouped by category */}
|
||||||
<div className="flex flex-col gap-2">
|
{!readOnly && <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] ?? ''
|
||||||
const visibleTags = categoryTags
|
const filtered = categoryTags.filter(
|
||||||
.filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase()))
|
(t) => !search || t.name.toLowerCase().includes(search.toLowerCase())
|
||||||
.slice(0, 25)
|
)
|
||||||
|
const visibleTags = [
|
||||||
|
...filtered.filter((t) => isAssigned(t.id)),
|
||||||
|
...filtered.filter((t) => !isAssigned(t.id)),
|
||||||
|
].slice(0, 25)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category.id}>
|
<div key={category.id}>
|
||||||
@@ -519,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ 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 }: Props) {
|
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename, downloadUrl }: 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)
|
||||||
@@ -79,7 +80,7 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Kebab menu */}
|
{/* Kebab menu */}
|
||||||
{onDelete && (
|
{(onDelete || downloadUrl) && (
|
||||||
<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) }}
|
||||||
@@ -94,6 +95,19 @@ 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,22 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode, RatingOperator } 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 }: Props) {
|
export default function TvView({ libraryId, readOnly }: 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[]>([])
|
||||||
@@ -30,8 +34,15 @@ export default function TvView({ libraryId }: 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(true)
|
const [showFilters, setShowFilters] = useState(
|
||||||
|
() => 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)
|
||||||
@@ -48,7 +59,12 @@ export default function TvView({ libraryId }: 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) => {
|
||||||
@@ -87,6 +103,7 @@ export default function TvView({ libraryId }: 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)
|
||||||
@@ -96,18 +113,17 @@ export default function TvView({ libraryId }: 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) => {
|
const openSeason = (season: TvSeason, index?: number) => {
|
||||||
|
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(
|
||||||
@@ -134,14 +150,24 @@ export default function TvView({ libraryId }: 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 = () => {
|
||||||
@@ -164,11 +190,18 @@ export default function TvView({ libraryId }: 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)}`,
|
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
)
|
)
|
||||||
.then(() => fetchSeries())
|
.then(() => fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`))
|
||||||
|
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,29 +345,114 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
// Escape key + body scroll lock when modal is open
|
||||||
|
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 filteredSeries = series.filter((s) => {
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
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 = seriesTags.length === 0 ? episodeTags
|
const allTags = [...new Set([...seriesTags, ...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 = episodes.filter((ep) => {
|
const filteredEpisodes = useMemo(() => episodes.filter((ep) => {
|
||||||
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
|
if (debouncedSearch) {
|
||||||
|
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
|
||||||
|
|
||||||
@@ -350,6 +468,7 @@ export default function TvView({ libraryId }: 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -438,6 +557,9 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -502,9 +624,76 @@ export default function TvView({ libraryId }: 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 */}
|
||||||
@@ -682,6 +871,11 @@ export default function TvView({ libraryId }: 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>
|
||||||
@@ -756,7 +950,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
{seasons.map((season) => (
|
{seasons.map((season) => (
|
||||||
<button
|
<button
|
||||||
key={season.id}
|
key={season.id}
|
||||||
onClick={() => openSeason(season)}
|
onClick={() => openSeason(season, seasons.indexOf(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) => {
|
||||||
@@ -792,7 +986,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'episodes' && selectedSeason && (
|
{view === 'episodes' && selectedSeason && (
|
||||||
<div>
|
<div className="p-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<EpisodeLoadingGrid />
|
<EpisodeLoadingGrid />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -808,7 +1002,8 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
key={ep.id}
|
key={ep.id}
|
||||||
episode={ep}
|
episode={ep}
|
||||||
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
||||||
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
|
onTag={() => { setTagPanelItemKey(ep.item_key!); setTagPanelDisabled(false); setShowTagPanel(true) }}
|
||||||
|
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!)}`,
|
||||||
@@ -838,42 +1033,91 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tagPanel && (
|
</div>
|
||||||
<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
|
|
||||||
onClick={() => setTagPanel(null)}
|
{/* Floating controls — tag + close */}
|
||||||
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
<button
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
|
||||||
aria-label="Close"
|
className={smallBtn}
|
||||||
>
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
✕
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||||
</button>
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||||||
|
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">
|
|
||||||
<TagSelector
|
{/* Right tag panel */}
|
||||||
itemKey={tagPanel.itemKey}
|
{showTagPanel && (
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
|
<MediaTagPanel
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
14
src/hooks/useDebounce.ts
Normal file
14
src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'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
|
||||||
|
}
|
||||||
@@ -5,5 +5,8 @@ export async function register() {
|
|||||||
|
|
||||||
const { startScheduler } = await import('./lib/scheduler')
|
const { startScheduler } = await import('./lib/scheduler')
|
||||||
startScheduler()
|
startScheduler()
|
||||||
|
|
||||||
|
const { initJobProcessor } = await import('./lib/ai-jobs')
|
||||||
|
initJobProcessor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
355
src/lib/ai-jobs.ts
Normal file
355
src/lib/ai-jobs.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
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
|
||||||
|
payload: 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,
|
||||||
|
payload?: Record<string, 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, payload)
|
||||||
|
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
|
||||||
|
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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(
|
||||||
|
"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, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Library, Tag, TagCategory } from '@/types'
|
import type { Library, Tag, TagCategory } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getAiConfig, 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 { getThumbnailPath, getVideoFramePaths } from './thumbnails'
|
import { getAiImagePath, getOcrImagePath, getVideoFramePaths } from './thumbnails'
|
||||||
import { findFile } from './media-utils'
|
import { findFile } from './media-utils'
|
||||||
import { getLibrary, resolveLibraryRoot } from './libraries'
|
import { getLibrary, resolveLibraryRoot } from './libraries'
|
||||||
|
|
||||||
@@ -105,10 +105,11 @@ interface TagPromptContext {
|
|||||||
mediaContext?: 'image' | 'video'
|
mediaContext?: 'image' | 'video'
|
||||||
aiDescription?: string | null
|
aiDescription?: string | null
|
||||||
extractedText?: string | null
|
extractedText?: string | null
|
||||||
|
customInstruction?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): string {
|
function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): string {
|
||||||
const { currentTags, mediaContext = 'image', aiDescription, extractedText } = ctx
|
const { currentTags, mediaContext = 'image', aiDescription, extractedText, customInstruction } = ctx
|
||||||
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
|
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||||
|
|
||||||
const grouped: Record<string, { id: string; name: string }[]> = {}
|
const grouped: Record<string, { id: string; name: string }[]> = {}
|
||||||
@@ -132,6 +133,11 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptCo
|
|||||||
'If no tags match, return an empty array (e.i., [])',
|
'If no tags match, return an empty array (e.i., [])',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (customInstruction) {
|
||||||
|
parts.push('')
|
||||||
|
parts.push(customInstruction)
|
||||||
|
}
|
||||||
|
|
||||||
if (aiDescription) {
|
if (aiDescription) {
|
||||||
parts.push('')
|
parts.push('')
|
||||||
parts.push(`AI-generated description of this content: ${aiDescription}`)
|
parts.push(`AI-generated description of this content: ${aiDescription}`)
|
||||||
@@ -165,7 +171,8 @@ 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'
|
||||||
|
|
||||||
@@ -189,7 +196,7 @@ async function callVisionApi(
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: 8192,
|
max_tokens: maxTokens,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -219,23 +226,19 @@ async function callVisionApi(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Run AI tagging for a single library. Called after the scanner finishes.
|
* Run AI tagging for a single library. Called after the scanner finishes.
|
||||||
* Processes up to BATCH_LIMIT untagged items per invocation.
|
* Enqueues up to BATCH_LIMIT untagged items as jobs for the processor.
|
||||||
*/
|
*/
|
||||||
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
||||||
const config = getAiConfig()
|
const config = getEffectiveAiConfig(library.id)
|
||||||
const taggingModel = config.modelTagging || config.model
|
const taggingModel = config.modelTagging || config.model
|
||||||
if (!config.enabled || !config.endpoint || !taggingModel) return
|
if (!config.enabled || !config.endpoint || !taggingModel) return
|
||||||
|
|
||||||
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
|
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
|
||||||
const allTags = getTags()
|
const allTags = getTags()
|
||||||
const allCategories = getCategories()
|
|
||||||
|
|
||||||
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
|
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
|
||||||
const categories = allCategories.filter((c) => activeCategoryIds.has(c.id))
|
|
||||||
if (tags.length === 0) return
|
if (tags.length === 0) return
|
||||||
|
|
||||||
const validTagIds = new Set(tags.map((t) => t.id))
|
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const untaggedItems = db
|
const untaggedItems = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -248,18 +251,13 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
|
|
||||||
if (untaggedItems.length === 0) return
|
if (untaggedItems.length === 0) return
|
||||||
|
|
||||||
console.log(`[ai-tagger] Processing ${untaggedItems.length} items in library "${library.name}"`)
|
// Import enqueueJob lazily to avoid circular dependency
|
||||||
|
const { enqueueJob } = await import('./ai-jobs')
|
||||||
|
|
||||||
let tagged = 0
|
let enqueued = 0
|
||||||
let consecutiveFailures = 0
|
|
||||||
const markTagged = db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?')
|
const markTagged = db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?')
|
||||||
|
|
||||||
for (const item of untaggedItems) {
|
for (const item of untaggedItems) {
|
||||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
||||||
console.warn(`[ai-tagger] Aborting after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedMedia = resolveItemImage(libraryRoot, item)
|
const resolvedMedia = resolveItemImage(libraryRoot, item)
|
||||||
if (!resolvedMedia) {
|
if (!resolvedMedia) {
|
||||||
// No image or video available — mark as tagged so we don't retry every scan
|
// No image or video available — mark as tagged so we don't retry every scan
|
||||||
@@ -267,47 +265,14 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
enqueueJob(item.item_key, 'tag', library.id)
|
||||||
let base64Images: string[]
|
// Mark as tagged immediately so subsequent scans don't re-enqueue
|
||||||
if (resolvedMedia.mediaType === 'video') {
|
markTagged.run(Date.now(), item.item_key)
|
||||||
const framePaths = await getVideoFramePaths(resolvedMedia.path, library.id, VIDEO_FRAME_PERCENTAGES)
|
enqueued++
|
||||||
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
|
||||||
} else {
|
|
||||||
const thumbnailPath = await getThumbnailPath(resolvedMedia.path, library.id, 'image')
|
|
||||||
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tags: currentItemTags } = getResolvedTagsForItem(item.item_key)
|
|
||||||
const aiFields = getAiFields(item.item_key)
|
|
||||||
const systemPrompt = buildTagPrompt(tags, categories, {
|
|
||||||
currentTags: currentItemTags,
|
|
||||||
mediaContext: resolvedMedia.mediaType,
|
|
||||||
aiDescription: aiFields.aiDescription,
|
|
||||||
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
|
||||||
})
|
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPrompt)
|
|
||||||
|
|
||||||
// Filter to valid tags only
|
|
||||||
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
|
||||||
for (const tagId of validIds) {
|
|
||||||
addTagToItem(item.item_key, tagId)
|
|
||||||
}
|
|
||||||
|
|
||||||
markTagged.run(Date.now(), item.item_key)
|
|
||||||
tagged++
|
|
||||||
consecutiveFailures = 0
|
|
||||||
} catch (err) {
|
|
||||||
consecutiveFailures++
|
|
||||||
console.warn(
|
|
||||||
`[ai-tagger] Failed to tag "${item.item_key}":`,
|
|
||||||
err instanceof Error ? err.message : err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagged > 0) {
|
if (enqueued > 0) {
|
||||||
console.log(`[ai-tagger] Tagged ${tagged}/${untaggedItems.length} items in library "${library.name}"`)
|
console.log(`[ai-tagger] Enqueued ${enqueued} tagging jobs for library "${library.name}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,14 +282,13 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
* Throws descriptive errors so the API route can return appropriate status codes.
|
* Throws descriptive errors so the API route can return appropriate status codes.
|
||||||
*/
|
*/
|
||||||
export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
||||||
const config = getAiConfig()
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const taggingModel = config.modelTagging || config.model
|
const taggingModel = config.modelTagging || config.model
|
||||||
if (!config.endpoint || !taggingModel) {
|
if (!config.endpoint || !taggingModel) {
|
||||||
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
|
||||||
|
|
||||||
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
|
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
|
||||||
const allTags = getTags()
|
const allTags = getTags()
|
||||||
const allCategories = getCategories()
|
const allCategories = getCategories()
|
||||||
@@ -361,7 +325,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
const framePaths = await getVideoFramePaths(imagePath.path, libraryId, VIDEO_FRAME_PERCENTAGES)
|
const framePaths = await getVideoFramePaths(imagePath.path, libraryId, VIDEO_FRAME_PERCENTAGES)
|
||||||
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
||||||
} else {
|
} else {
|
||||||
const thumbnailPath = await getThumbnailPath(imagePath.path, libraryId, 'image')
|
const thumbnailPath = await getAiImagePath(imagePath.path, libraryId)
|
||||||
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,9 +336,10 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
mediaContext: imagePath.mediaType,
|
mediaContext: imagePath.mediaType,
|
||||||
aiDescription: aiFields.aiDescription,
|
aiDescription: aiFields.aiDescription,
|
||||||
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
||||||
|
customInstruction: config.promptTagger || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
|
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag)
|
||||||
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) {
|
||||||
@@ -395,7 +360,8 @@ 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'
|
||||||
|
|
||||||
@@ -419,7 +385,7 @@ async function callVisionApiText(
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: 8192,
|
max_tokens: maxTokens,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -446,7 +412,8 @@ 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'
|
||||||
|
|
||||||
@@ -464,7 +431,7 @@ async function callChatApiText(
|
|||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: userMessage },
|
{ role: 'user', content: userMessage },
|
||||||
],
|
],
|
||||||
max_tokens: 8192,
|
max_tokens: maxTokens,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -491,13 +458,13 @@ async function callChatApiText(
|
|||||||
* Stores the result in the ai_description column and returns it.
|
* Stores the result in the ai_description column and returns it.
|
||||||
*/
|
*/
|
||||||
export async function generateItemDescription(itemKey: string): Promise<string> {
|
export async function generateItemDescription(itemKey: string): Promise<string> {
|
||||||
const config = getAiConfig()
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const describeModel = config.modelDescribe || config.model
|
const describeModel = config.modelDescribe || config.model
|
||||||
if (!config.endpoint || !describeModel) {
|
if (!config.endpoint || !describeModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
||||||
@@ -522,13 +489,17 @@ export async function generateItemDescription(itemKey: string): Promise<string>
|
|||||||
const framePaths = await getVideoFramePaths(resolvedMedia.path, libraryId, VIDEO_FRAME_PERCENTAGES)
|
const framePaths = await getVideoFramePaths(resolvedMedia.path, libraryId, VIDEO_FRAME_PERCENTAGES)
|
||||||
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
||||||
} else {
|
} else {
|
||||||
const thumbnailPath = await getThumbnailPath(resolvedMedia.path, libraryId, 'image')
|
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
||||||
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = 'You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences. Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.'
|
const { tags: currentTags } = getResolvedTagsForItem(itemKey)
|
||||||
|
const tagContext = currentTags.length > 0
|
||||||
|
? ` This content has the following tags applied describing it: ${currentTags.map((t) => t.name).join(', ')}. Use these as additional context and treat them as a source of truth, overriding any conflicting assumptions made from the image.`
|
||||||
|
: ''
|
||||||
|
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)
|
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt, config.maxTokensDescribe)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -538,19 +509,39 @@ export async function generateItemDescription(itemKey: string): Promise<string>
|
|||||||
// ─── Text extraction ─────────────────────────────────────────────────────────
|
// ─── Text extraction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract text (OCR) from an image using the vision model.
|
* Run Tesseract OCR on a preprocessed image file.
|
||||||
* Only works for images in mixed libraries.
|
* Returns the extracted text and a mean confidence score (0–100).
|
||||||
* If the extracted text is not in the user's preferred language, auto-translates it.
|
* A confidence of 0 with empty text means no recognisable text was found.
|
||||||
* Returns { extractedText, translatedText }.
|
|
||||||
*/
|
*/
|
||||||
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
async function extractWithTesseract(
|
||||||
const config = getAiConfig()
|
imagePath: string,
|
||||||
const extractModel = config.modelExtract || config.model
|
languages: string,
|
||||||
if (!config.endpoint || !extractModel) {
|
): Promise<{ text: string; confidence: number }> {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
const { createWorker } = await import('tesseract.js')
|
||||||
|
const workerPath = path.join(process.cwd(), 'node_modules/tesseract.js/src/worker-script/node/index.js')
|
||||||
|
const worker = await createWorker(languages, 1, { workerPath })
|
||||||
|
try {
|
||||||
|
const { data } = await worker.recognize(imagePath)
|
||||||
|
return { text: data.text.trim(), confidence: data.confidence }
|
||||||
|
} finally {
|
||||||
|
await worker.terminate()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
||||||
@@ -576,44 +567,60 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
|
|||||||
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 thumbnailPath = await getThumbnailPath(resolvedMedia.path, libraryId, '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 base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
|
|
||||||
const systemPrompt = 'You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting. Be mindful of different colors of text that may indicate different speakers or emphasis. If there is no text in the image, respond with exactly: [NO TEXT]'
|
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]`
|
||||||
|
|
||||||
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
|
console.log(`[ocr] llm used for ${itemKey} (mode=${ocrMode})`)
|
||||||
|
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
|
||||||
|
|
||||||
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 = ? WHERE item_key = ?').run(extractedText, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(extractedText, itemKey)
|
||||||
|
|
||||||
// Auto-translate if preferred language is set
|
return { extractedText, translatedText: null }
|
||||||
const preferredLanguage = getPreferredLanguage()
|
|
||||||
let translatedText: string | null = null
|
|
||||||
if (preferredLanguage) {
|
|
||||||
const translateModel = config.modelTranslate || config.model
|
|
||||||
try {
|
|
||||||
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage)
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate the extracted_text of an item into the preferred language.
|
* Translate the extracted_text of an item into the preferred language.
|
||||||
* Returns the translated text or null if no text to translate.
|
* Returns the translated text or null if no text to translate.
|
||||||
*/
|
*/
|
||||||
export async function translateItemText(itemKey: string): Promise<string | null> {
|
export async function translateItemText(itemKey: string, sourceLanguage?: string): Promise<string | null> {
|
||||||
const config = getAiConfig()
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const translateModel = config.modelTranslate || config.model
|
const translateModel = config.modelTranslate || config.model
|
||||||
if (!config.endpoint || !translateModel) {
|
if (!config.endpoint || !translateModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
@@ -633,7 +640,7 @@ export async function translateItemText(itemKey: string): Promise<string | null>
|
|||||||
const preferredLanguage = getPreferredLanguage()
|
const preferredLanguage = getPreferredLanguage()
|
||||||
if (!preferredLanguage) return null
|
if (!preferredLanguage) return null
|
||||||
|
|
||||||
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage)
|
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, 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)
|
||||||
}
|
}
|
||||||
@@ -641,6 +648,22 @@ export async function translateItemText(itemKey: string): Promise<string | null>
|
|||||||
return translatedText
|
return translatedText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the extracted_text of an item.
|
||||||
|
*/
|
||||||
|
export function updateExtractedText(itemKey: string, text: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
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.
|
||||||
@@ -649,17 +672,25 @@ async function translateText(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
model: string,
|
model: string,
|
||||||
text: string,
|
text: string,
|
||||||
targetLanguage: string
|
targetLanguage: string,
|
||||||
|
customInstruction = '',
|
||||||
|
maxTokens = 8192,
|
||||||
|
sourceLanguage?: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const 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}. Return ONLY the translated text with no additional commentary.`
|
let systemPrompt: string
|
||||||
|
if (sourceLanguage) {
|
||||||
|
systemPrompt = `You are a translator. Translate the following text from ${sourceLanguage} to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
|
||||||
|
} else {
|
||||||
|
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)
|
const result = await callChatApiText(endpoint, model, systemPrompt, text, maxTokens)
|
||||||
|
|
||||||
if (result === '[ALREADY_TARGET_LANGUAGE]' || !result) {
|
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -667,7 +698,7 @@ async function translateText(
|
|||||||
* Returns the number of items processed.
|
* Returns the number of items processed.
|
||||||
*/
|
*/
|
||||||
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
|
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
|
||||||
const config = getAiConfig()
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const extractModel = config.modelExtract || config.model
|
const extractModel = config.modelExtract || config.model
|
||||||
if (!config.endpoint || !extractModel) {
|
if (!config.endpoint || !extractModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
@@ -718,7 +749,7 @@ export async function extractDirectoryText(libraryId: string, dirPath: string):
|
|||||||
* Returns the number of items processed.
|
* Returns the number of items processed.
|
||||||
*/
|
*/
|
||||||
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
|
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
|
||||||
const config = getAiConfig()
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const describeModel = config.modelDescribe || config.model
|
const describeModel = config.modelDescribe || config.model
|
||||||
if (!config.endpoint || !describeModel) {
|
if (!config.endpoint || !describeModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
|||||||
@@ -39,7 +39,16 @@ export function setScanLastRan(ts: number): void {
|
|||||||
|
|
||||||
// ─── AI Settings ─────────────────────────────────────────────────────────────
|
// ─── AI Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AiConfig {
|
const DEFAULT_PROMPT_DESCRIBE =
|
||||||
|
'Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.'
|
||||||
|
const DEFAULT_PROMPT_TAGGER = ''
|
||||||
|
const DEFAULT_PROMPT_EXTRACT =
|
||||||
|
'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.'
|
||||||
|
|
||||||
|
export type OcrMode = 'hybrid' | 'tesseract' | 'llm'
|
||||||
|
|
||||||
|
export interface AiConfig {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
model: string
|
model: string
|
||||||
modelTagging: string
|
modelTagging: string
|
||||||
@@ -47,6 +56,17 @@ interface AiConfig {
|
|||||||
modelExtract: string
|
modelExtract: string
|
||||||
modelTranslate: string
|
modelTranslate: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
promptDescribe: string
|
||||||
|
promptTagger: string
|
||||||
|
promptExtract: 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 {
|
||||||
@@ -57,7 +77,28 @@ export function getAiConfig(): AiConfig {
|
|||||||
const modelExtract = getSetting('ai_model_extract') ?? ''
|
const modelExtract = getSetting('ai_model_extract') ?? ''
|
||||||
const modelTranslate = getSetting('ai_model_translate') ?? ''
|
const modelTranslate = getSetting('ai_model_translate') ?? ''
|
||||||
const enabled = getSetting('ai_enabled') === 'true'
|
const enabled = getSetting('ai_enabled') === 'true'
|
||||||
return { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled }
|
const promptDescribeRaw = getSetting('ai_prompt_describe')
|
||||||
|
const promptDescribe = promptDescribeRaw !== null ? promptDescribeRaw : DEFAULT_PROMPT_DESCRIBE
|
||||||
|
const promptTaggerRaw = getSetting('ai_prompt_tagger')
|
||||||
|
const promptTagger = promptTaggerRaw !== null ? promptTaggerRaw : DEFAULT_PROMPT_TAGGER
|
||||||
|
const promptExtractRaw = getSetting('ai_prompt_extract')
|
||||||
|
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
|
||||||
|
const promptTranslateRaw = getSetting('ai_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 {
|
||||||
|
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
|
||||||
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
|
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
||||||
|
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAiConfig(
|
export function updateAiConfig(
|
||||||
@@ -68,6 +109,17 @@ export function updateAiConfig(
|
|||||||
modelDescribe?: string,
|
modelDescribe?: string,
|
||||||
modelExtract?: string,
|
modelExtract?: string,
|
||||||
modelTranslate?: string,
|
modelTranslate?: string,
|
||||||
|
promptDescribe?: string,
|
||||||
|
promptTagger?: string,
|
||||||
|
promptExtract?: 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)
|
||||||
@@ -76,6 +128,17 @@ export function updateAiConfig(
|
|||||||
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
|
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
|
||||||
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
|
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
|
||||||
if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
|
if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
|
||||||
|
if (promptDescribe !== undefined) setSetting('ai_prompt_describe', promptDescribe)
|
||||||
|
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
|
||||||
|
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
|
||||||
|
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 {
|
||||||
@@ -85,3 +148,137 @@ export function getPreferredLanguage(): string {
|
|||||||
export function setPreferredLanguage(language: string): void {
|
export function setPreferredLanguage(language: string): void {
|
||||||
setSetting('preferred_language', language)
|
setSetting('preferred_language', language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Per-library AI overrides ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LibraryAiOverrides {
|
||||||
|
modelTagging: string
|
||||||
|
modelDescribe: string
|
||||||
|
modelExtract: string
|
||||||
|
modelTranslate: string
|
||||||
|
promptDescribe: string
|
||||||
|
promptTagger: string
|
||||||
|
promptExtract: string
|
||||||
|
promptTranslate: string
|
||||||
|
maxTokensTag: number | null
|
||||||
|
maxTokensDescribe: number | null
|
||||||
|
maxTokensExtract: number | null
|
||||||
|
maxTokensTranslate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibraryAiSettingsRow {
|
||||||
|
model_tagging: string | null
|
||||||
|
model_describe: string | null
|
||||||
|
model_extract: string | null
|
||||||
|
model_translate: string | null
|
||||||
|
prompt_describe: string | null
|
||||||
|
prompt_tagger: string | null
|
||||||
|
prompt_extract: 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 {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM library_ai_settings WHERE library_id = ?')
|
||||||
|
.get(libraryId) as LibraryAiSettingsRow | undefined
|
||||||
|
return {
|
||||||
|
modelTagging: row?.model_tagging ?? '',
|
||||||
|
modelDescribe: row?.model_describe ?? '',
|
||||||
|
modelExtract: row?.model_extract ?? '',
|
||||||
|
modelTranslate: row?.model_translate ?? '',
|
||||||
|
promptDescribe: row?.prompt_describe ?? '',
|
||||||
|
promptTagger: row?.prompt_tagger ?? '',
|
||||||
|
promptExtract: row?.prompt_extract ?? '',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLibraryAiOverrides(libraryId: string, overrides: Partial<LibraryAiOverrides>): void {
|
||||||
|
const db = getDb()
|
||||||
|
// Ensure a row exists
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
|
||||||
|
).run(libraryId)
|
||||||
|
|
||||||
|
const stringFields: Record<string, string | undefined> = {
|
||||||
|
model_tagging: overrides.modelTagging,
|
||||||
|
model_describe: overrides.modelDescribe,
|
||||||
|
model_extract: overrides.modelExtract,
|
||||||
|
model_translate: overrides.modelTranslate,
|
||||||
|
prompt_describe: overrides.promptDescribe,
|
||||||
|
prompt_tagger: overrides.promptTagger,
|
||||||
|
prompt_extract: overrides.promptExtract,
|
||||||
|
prompt_translate: overrides.promptTranslate,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [col, val] of Object.entries(stringFields)) {
|
||||||
|
if (val !== undefined) {
|
||||||
|
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
|
||||||
|
val === '' ? null : val,
|
||||||
|
libraryId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const global = getAiConfig()
|
||||||
|
const overrides = getLibraryAiOverrides(libraryId)
|
||||||
|
return {
|
||||||
|
endpoint: global.endpoint,
|
||||||
|
model: global.model,
|
||||||
|
enabled: global.enabled,
|
||||||
|
modelTagging: overrides.modelTagging || global.modelTagging,
|
||||||
|
modelDescribe: overrides.modelDescribe || global.modelDescribe,
|
||||||
|
modelExtract: overrides.modelExtract || global.modelExtract,
|
||||||
|
modelTranslate: overrides.modelTranslate || global.modelTranslate,
|
||||||
|
promptDescribe: overrides.promptDescribe || global.promptDescribe,
|
||||||
|
promptTagger: overrides.promptTagger || global.promptTagger,
|
||||||
|
promptExtract: overrides.promptExtract || global.promptExtract,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI Max Retries ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getAiMaxRetries(): number {
|
||||||
|
const raw = getSetting('ai_max_retries')
|
||||||
|
const parsed = parseInt(raw ?? '3', 10)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAiMaxRetries(n: number): void {
|
||||||
|
setSetting('ai_max_retries', String(Math.max(0, Math.floor(n))))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> }
|
type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' }
|
||||||
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,13 +100,22 @@ 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 }
|
if (session.role === 'admin') return { session, accessLevel: 'admin' }
|
||||||
|
|
||||||
// Lazy import to avoid pulling DB into edge contexts
|
// Lazy import to avoid pulling DB into edge contexts
|
||||||
const { getPermittedLibraryIds } = await import('./users')
|
const { getLibraryAccessLevel } = await import('./users')
|
||||||
const permitted = getPermittedLibraryIds(session.userId)
|
const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
|
||||||
if (!permitted.includes(libraryId)) {
|
if (!accessLevel) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
return { session }
|
return { session, accessLevel }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
120
src/lib/comic-info.ts
Normal file
120
src/lib/comic-info.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/lib/comic-metadata.ts
Normal file
230
src/lib/comic-metadata.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
377
src/lib/comics.ts
Normal file
377
src/lib/comics.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/lib/db.ts
205
src/lib/db.ts
@@ -12,7 +12,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +109,16 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateMediaTagsToItemKey(db)
|
migrateMediaTagsToItemKey(db)
|
||||||
migrateMediaItemsAiTagged(db)
|
migrateMediaItemsAiTagged(db)
|
||||||
migrateMediaItemsAiFields(db)
|
migrateMediaItemsAiFields(db)
|
||||||
|
migrateLibraryAiSettings(db)
|
||||||
|
migrateAiJobs(db)
|
||||||
|
migrateLibraryPermissionsAccessLevel(db)
|
||||||
|
migrateLibrariesAddComics(db)
|
||||||
|
migrateComicItemTypes(db)
|
||||||
|
migrateImportedTags(db)
|
||||||
|
migrateComicsIndex(db)
|
||||||
|
migrateTagMappingsIndexes(db)
|
||||||
|
migrateUserRating(db)
|
||||||
|
migrateParentKeyItemTypeIndex(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +131,11 @@ function seedAppSettings(db: Database.Database): void {
|
|||||||
ai_endpoint: '',
|
ai_endpoint: '',
|
||||||
ai_model: '',
|
ai_model: '',
|
||||||
preferred_language: 'English',
|
preferred_language: 'English',
|
||||||
|
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 (?, ?)'
|
||||||
@@ -259,6 +279,35 @@ function migrateMediaItemsAiFields(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateLibraryAiSettings(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS library_ai_settings (
|
||||||
|
library_id TEXT PRIMARY KEY REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
model_tagging TEXT,
|
||||||
|
model_describe TEXT,
|
||||||
|
model_extract TEXT,
|
||||||
|
model_translate TEXT,
|
||||||
|
prompt_describe TEXT,
|
||||||
|
prompt_tagger TEXT,
|
||||||
|
prompt_extract 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 {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||||
@@ -281,3 +330,159 @@ 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 {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
item_key TEXT NOT NULL,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
job_type TEXT NOT NULL CHECK(job_type IN ('tag','describe','extract','translate')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','completed','failed')),
|
||||||
|
error TEXT,
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
started_at INTEGER,
|
||||||
|
completed_at INTEGER,
|
||||||
|
item_title TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 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,12 +74,16 @@ 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 function scanDirectoryRecursive(
|
export async function scanDirectoryRecursive(
|
||||||
libraryRoot: string,
|
libraryRoot: string,
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
subpath: string
|
subpath: string
|
||||||
): DirectoryListing {
|
): Promise<DirectoryListing> {
|
||||||
let rootAbsPath: string
|
let rootAbsPath: string
|
||||||
try {
|
try {
|
||||||
rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot
|
rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot
|
||||||
@@ -89,35 +93,37 @@ export function scanDirectoryRecursive(
|
|||||||
|
|
||||||
const entries: FileEntry[] = []
|
const entries: FileEntry[] = []
|
||||||
|
|
||||||
function walk(absDir: string, relDir: string): void {
|
async function walk(absDir: string, relDir: string): Promise<void> {
|
||||||
let dirents: fs.Dirent[]
|
let dirents: fs.Dirent[]
|
||||||
try {
|
try {
|
||||||
dirents = fs.readdirSync(absDir, { withFileTypes: true })
|
dirents = await fs.promises.readdir(absDir, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const d of dirents) {
|
await Promise.all(
|
||||||
if (HIDDEN_FILES.test(d.name)) continue
|
dirents.map(async (d) => {
|
||||||
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
if (HIDDEN_FILES.test(d.name)) return
|
||||||
if (d.isDirectory()) {
|
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
||||||
walk(path.join(absDir, d.name), relPath)
|
if (d.isDirectory()) {
|
||||||
} else {
|
await walk(path.join(absDir, d.name), relPath)
|
||||||
const mediaType = getMediaType(d.name)
|
} else {
|
||||||
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
const mediaType = getMediaType(d.name)
|
||||||
// name = full relative path from library root so media keys match
|
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
||||||
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
// name = full relative path from library root so media keys match
|
||||||
entries.push({
|
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
||||||
name: fullRelPath,
|
entries.push({
|
||||||
type: 'file',
|
name: fullRelPath,
|
||||||
mediaType,
|
type: 'file',
|
||||||
url: fileApiUrl(libraryId, fullRelPath),
|
mediaType,
|
||||||
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
url: fileApiUrl(libraryId, fullRelPath),
|
||||||
})
|
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
walk(rootAbsPath, '')
|
await 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,6 +93,10 @@ function buildGame(
|
|||||||
: null,
|
: null,
|
||||||
gameFiles,
|
gameFiles,
|
||||||
platforms,
|
platforms,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +179,15 @@ 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`)
|
||||||
@@ -233,6 +242,10 @@ 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)
|
||||||
|
|||||||
103
src/lib/movie-metadata.ts
Normal file
103
src/lib/movie-metadata.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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,6 +72,10 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,10 @@ 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) => {
|
||||||
@@ -108,6 +116,10 @@ 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,16 +1,20 @@
|
|||||||
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 } from '@/types'
|
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries, ComicIssue } 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 { getThumbnailPath } from './thumbnails'
|
import { scanComicsLibrary, type ScannedComicSeries } from './comics'
|
||||||
|
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
|
||||||
|
|
||||||
@@ -70,6 +74,9 @@ 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) =>
|
||||||
@@ -536,6 +543,208 @@ 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,6 +98,62 @@ 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[] {
|
||||||
@@ -263,6 +319,52 @@ 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,10 +3,13 @@ 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
|
||||||
const JPEG_QUALITY = 75
|
const JPEG_QUALITY = 75
|
||||||
|
const AI_IMAGE_WIDTH = 1920
|
||||||
|
const AI_JPEG_QUALITY = 90
|
||||||
|
|
||||||
/** Ensure the cache directory exists. */
|
/** Ensure the cache directory exists. */
|
||||||
function ensureCacheDir(): void {
|
function ensureCacheDir(): void {
|
||||||
@@ -47,6 +50,30 @@ async function generateImageThumbnail(src: string, dest: string): Promise<void>
|
|||||||
fs.renameSync(tmp, dest)
|
fs.renameSync(tmp, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate a high-resolution JPEG for AI vision use. Images smaller than
|
||||||
|
* AI_IMAGE_WIDTH are not upscaled — they are converted at their native size. */
|
||||||
|
async function generateAiImage(src: string, dest: string): Promise<void> {
|
||||||
|
const tmp = dest + '.tmp'
|
||||||
|
await sharp(src)
|
||||||
|
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: AI_JPEG_QUALITY })
|
||||||
|
.toFile(tmp)
|
||||||
|
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) => {
|
||||||
@@ -158,6 +185,70 @@ export async function getVideoFramePaths(
|
|||||||
return framePaths
|
return framePaths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute path to a high-resolution JPEG suitable for AI vision
|
||||||
|
* APIs (1920px wide max, quality 90). Cached alongside display thumbnails with
|
||||||
|
* an `_ai` suffix so display performance is unaffected.
|
||||||
|
* Generates on first call or when the source file has been modified.
|
||||||
|
*/
|
||||||
|
export async function getAiImagePath(
|
||||||
|
absoluteFilePath: string,
|
||||||
|
libraryId: string
|
||||||
|
): Promise<string> {
|
||||||
|
ensureCacheDir()
|
||||||
|
const key = cacheKey(libraryId, absoluteFilePath)
|
||||||
|
const cacheFile = path.join(CACHE_DIR, key + '_ai.jpg')
|
||||||
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||||
|
if (cached) return cached
|
||||||
|
await generateAiImage(absoluteFilePath, 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).
|
||||||
|
|||||||
142
src/lib/tv-metadata.ts
Normal file
142
src/lib/tv-metadata.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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,6 +3,7 @@ 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())
|
||||||
@@ -52,6 +53,7 @@ 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) => {
|
||||||
@@ -67,11 +69,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
|||||||
|
|
||||||
series.push({
|
series.push({
|
||||||
id,
|
id,
|
||||||
title: dirName,
|
title: nfo?.title ?? dirName,
|
||||||
year: null,
|
year: nfo?.year ?? null,
|
||||||
plot: null,
|
plot: nfo?.plot ?? null,
|
||||||
genres: [],
|
genres: nfo?.genres ?? [],
|
||||||
status: null,
|
status: nfo?.status ?? null,
|
||||||
posterUrl: posterFile
|
posterUrl: posterFile
|
||||||
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||||
: null,
|
: null,
|
||||||
@@ -79,6 +81,10 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +185,10 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +212,10 @@ 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[] {
|
||||||
@@ -225,6 +239,10 @@ 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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -288,6 +306,10 @@ 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,43 +77,60 @@ export function listUsers(): User[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPermittedLibraryIds(userId: string): string[] {
|
export interface LibraryPermission {
|
||||||
const db = getDb()
|
libraryId: string
|
||||||
const rows = db
|
accessLevel: 'read' | 'write'
|
||||||
.prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
|
|
||||||
.all(userId) as { library_id: string }[]
|
|
||||||
return rows.map((r) => r.library_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLibraryPermissions(userId: string, libraryIds: string[]): void {
|
export function getLibraryPermissions(userId: string): LibraryPermission[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT library_id, access_level FROM library_permissions WHERE user_id = ?')
|
||||||
|
.all(userId) as { library_id: string; access_level: string }[]
|
||||||
|
return rows.map((r) => ({ libraryId: r.library_id, accessLevel: r.access_level as 'read' | 'write' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLibraryAccessLevel(userId: string, libraryId: string): 'read' | 'write' | null {
|
||||||
|
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('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)')
|
const insert = db.prepare(
|
||||||
for (const libraryId of libraryIds) {
|
'INSERT INTO library_permissions (user_id, library_id, access_level) VALUES (?, ?, ?)'
|
||||||
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()
|
if (role === 'admin') return getLibraries().map((l) => ({ ...l, accessLevel: 'admin' as const }))
|
||||||
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
|
`SELECT l.id, l.name, l.path, l.type, l.cover_ext, lp.access_level
|
||||||
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 }[]
|
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null; access_level: string }[]
|
||||||
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',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
232
src/lib/zip-utils.ts
Normal file
232
src/lib/zip-utils.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
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,4 +1,33 @@
|
|||||||
export type LibraryType = 'games' | 'mixed' | 'movies' | 'tv'
|
export type LibraryType = 'comics' | '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
|
||||||
@@ -6,6 +35,7 @@ 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'
|
||||||
@@ -25,6 +55,10 @@ 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 {
|
||||||
@@ -44,6 +78,11 @@ 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 {
|
||||||
@@ -59,6 +98,10 @@ 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 {
|
||||||
@@ -73,6 +116,10 @@ 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 {
|
||||||
@@ -96,6 +143,10 @@ 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 {
|
||||||
@@ -125,3 +176,32 @@ 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