Compare commits
25 Commits
1350a6f94b
...
scanning-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,4 +9,5 @@ medialore.db-shm
|
|||||||
medialore.db-wal
|
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
|
||||||
|
|||||||
117
package-lock.json
generated
117
package-lock.json
generated
@@ -17,7 +17,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",
|
||||||
@@ -2950,6 +2951,12 @@
|
|||||||
"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.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -4803,6 +4810,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 +5301,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",
|
||||||
@@ -6167,6 +6186,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 +6354,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 +6795,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 +7639,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 +7733,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",
|
||||||
@@ -7955,6 +8039,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 +8343,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",
|
||||||
|
|||||||
@@ -20,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",
|
||||||
|
|||||||
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,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries } 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)
|
||||||
@@ -34,6 +34,9 @@ export async function PUT(request: NextRequest) {
|
|||||||
maxTokensDescribe?: number
|
maxTokensDescribe?: number
|
||||||
maxTokensExtract?: number
|
maxTokensExtract?: number
|
||||||
maxTokensTranslate?: number
|
maxTokensTranslate?: number
|
||||||
|
ocrMode?: string
|
||||||
|
ocrLanguages?: string
|
||||||
|
ocrConfidenceThreshold?: number
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -47,6 +50,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
||||||
|
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (typeof endpoint !== 'string') {
|
if (typeof endpoint !== 'string') {
|
||||||
@@ -75,6 +79,9 @@ export async function PUT(request: NextRequest) {
|
|||||||
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
|
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
|
||||||
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
|
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
|
||||||
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
|
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
|
||||||
|
(ocrMode === 'hybrid' || ocrMode === 'tesseract' || ocrMode === 'llm') ? (ocrMode as OcrMode) : undefined,
|
||||||
|
typeof ocrLanguages === 'string' ? ocrLanguages : undefined,
|
||||||
|
typeof ocrConfidenceThreshold === 'number' ? ocrConfidenceThreshold : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
|
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
||||||
|
|||||||
@@ -1,24 +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 { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
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
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'extract', libraryId)
|
const payload: Record<string, string> = {}
|
||||||
|
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
|
||||||
|
if (ocrMode) payload.ocrMode = ocrMode
|
||||||
|
const jobId = enqueueJob(
|
||||||
|
itemKey,
|
||||||
|
'extract',
|
||||||
|
libraryId,
|
||||||
|
undefined,
|
||||||
|
Object.keys(payload).length ? payload : undefined,
|
||||||
|
)
|
||||||
return NextResponse.json({ jobId }, { status: 202 })
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { getAiFields, updateExtractedText } 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
|
||||||
@@ -19,25 +19,37 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
let body: { itemKey?: string; extractedText?: string }
|
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemKey, extractedText } = body
|
const { itemKey, extractedText, aiDescription } = body
|
||||||
if (!itemKey || typeof itemKey !== 'string') {
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
if (typeof extractedText !== 'string') {
|
if (extractedText === undefined && aiDescription === undefined) {
|
||||||
return NextResponse.json({ error: 'extractedText is required' }, { status: 400 })
|
return NextResponse.json({ error: 'extractedText or aiDescription 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
|
||||||
|
|
||||||
updateExtractedText(itemKey, extractedText)
|
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 })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
||||||
|
|||||||
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,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
||||||
|
|||||||
@@ -1,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'
|
||||||
@@ -31,6 +32,40 @@ export async function GET(request: NextRequest) {
|
|||||||
const listing = recursive
|
const listing = recursive
|
||||||
? scanDirectoryRecursive(root, libraryId, subpath)
|
? scanDirectoryRecursive(root, libraryId, subpath)
|
||||||
: scanDirectory(root, libraryId, subpath)
|
: scanDirectory(root, libraryId, subpath)
|
||||||
|
|
||||||
|
// Annotate image files with hasExtractedText, and directories if any descendant has extracted text
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
|
||||||
|
.all(libraryId) as { item_key: string }[]
|
||||||
|
const withText = new Set(rows.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') {
|
||||||
|
if (e.mediaType !== 'image') return e
|
||||||
|
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
||||||
|
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
||||||
|
return { ...e, hasExtractedText: withText.has(itemKey) }
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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,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,7 @@
|
|||||||
import { getLibrary } from '@/lib/libraries'
|
import { getLibrary } from '@/lib/libraries'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { getServerSession } from '@/lib/auth'
|
import { getServerSession } from '@/lib/auth'
|
||||||
import { getPermittedLibraryIds } from '@/lib/users'
|
import { getLibraryAccessLevel } from '@/lib/users'
|
||||||
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 +23,41 @@ 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 === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
|
||||||
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'tv' && <TvView libraryId={id} />}
|
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface AiSettings {
|
|||||||
maxTokensDescribe: number
|
maxTokensDescribe: number
|
||||||
maxTokensExtract: number
|
maxTokensExtract: number
|
||||||
maxTokensTranslate: number
|
maxTokensTranslate: number
|
||||||
|
ocrMode: 'hybrid' | 'tesseract' | 'llm'
|
||||||
|
ocrLanguages: string
|
||||||
|
ocrConfidenceThreshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AiJob {
|
interface AiJob {
|
||||||
@@ -76,6 +79,7 @@ export default function AiTaggingPage() {
|
|||||||
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
maxTokensTag: 8192, maxTokensDescribe: 8192, maxTokensExtract: 8192, maxTokensTranslate: 8192,
|
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)
|
||||||
@@ -644,6 +648,72 @@ export default function AiTaggingPage() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
|
const [extractPending, setExtractPending] = useState(false)
|
||||||
|
|
||||||
const 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)
|
||||||
|
|
||||||
@@ -126,14 +128,19 @@ 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 extracted text for current item
|
// Fetch extracted text for current item; clear any in-flight poll on item change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (extractPollRef.current) {
|
||||||
|
clearInterval(extractPollRef.current)
|
||||||
|
extractPollRef.current = null
|
||||||
|
}
|
||||||
setExtractedText(null)
|
setExtractedText(null)
|
||||||
setTranslatedText(null)
|
setTranslatedText(null)
|
||||||
setShowTextOverlay(false)
|
setShowTextOverlay(false)
|
||||||
setShowOriginal(false)
|
setShowOriginal(false)
|
||||||
setExtracting(false)
|
setExtracting(false)
|
||||||
setExtractError(null)
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -144,6 +151,13 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [current?.itemKey])
|
}, [current?.itemKey])
|
||||||
|
|
||||||
|
// Clean up poll on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') { onClose(); return }
|
if (e.key === 'Escape') { onClose(); return }
|
||||||
@@ -184,23 +198,44 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
|
|
||||||
const handleExtractText = async () => {
|
const handleExtractText = async () => {
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
|
const itemKey = current.itemKey
|
||||||
setExtracting(true)
|
setExtracting(true)
|
||||||
setExtractError(null)
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey: current.itemKey }),
|
body: JSON.stringify({ itemKey }),
|
||||||
})
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
// Job queued — poll until it completes (up to 5 min)
|
||||||
|
setExtractPending(true)
|
||||||
|
const deadline = Date.now() + 5 * 60 * 1000
|
||||||
|
extractPollRef.current = setInterval(async () => {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||||
|
setExtractPending(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()
|
||||||
|
if (data.extractedText) {
|
||||||
|
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||||
|
setExtractPending(false)
|
||||||
|
setExtractedText(data.extractedText)
|
||||||
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
|
setShowTextOverlay(true)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||||
}
|
}
|
||||||
if (res.status === 202) {
|
|
||||||
setExtractError('Queued — check AI Integrations for progress')
|
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
setExtractedText(result.extractedText || null)
|
setExtractedText(result.extractedText || null)
|
||||||
setTranslatedText(result.translatedText || null)
|
setTranslatedText(result.translatedText || null)
|
||||||
@@ -301,7 +336,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
{/* Text overlay */}
|
{/* Text overlay */}
|
||||||
{showTextOverlay && displayText && (
|
{showTextOverlay && displayText && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-16 left-4 right-4 z-20 rounded-xl p-4"
|
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)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -371,15 +406,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleExtractText}
|
onClick={handleExtractText}
|
||||||
disabled={extracting}
|
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"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: extractError ? 'rgba(127,29,29,0.8)' : 'rgba(0,0,0,0.5)',
|
backgroundColor: extractPending
|
||||||
|
? 'var(--accent)'
|
||||||
|
: extractError
|
||||||
|
? 'rgba(127,29,29,0.8)'
|
||||||
|
: 'rgba(0,0,0,0.5)',
|
||||||
color: extractError ? '#fca5a5' : '#fff',
|
color: extractError ? '#fca5a5' : '#fff',
|
||||||
}}
|
}}
|
||||||
aria-label="Extract text"
|
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
||||||
|
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
||||||
>
|
>
|
||||||
{extracting ? (
|
{extracting || extractPending ? (
|
||||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
||||||
) : (
|
) : (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -591,7 +677,7 @@ function DownloadButton({
|
|||||||
<span>↓</span>
|
<span>↓</span>
|
||||||
<span className="truncate">{primary.filename}</span>
|
<span className="truncate">{primary.filename}</span>
|
||||||
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
|
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,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)
|
||||||
@@ -72,7 +73,10 @@ export default function GamesView({ 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 [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) => {
|
||||||
@@ -147,6 +151,9 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
const filteredGames: Game[] = filtered.flatMap((item) =>
|
||||||
|
'games' in item ? item.games : [item as Game]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -220,7 +227,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 +238,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 +304,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 +321,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,29 +12,48 @@ 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(false)
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
|
|
||||||
// Text extraction state
|
// Text extraction state
|
||||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
const [extractPending, setExtractPending] = useState(false)
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
const [retranslating, setRetranslating] = useState(false)
|
const [retranslating, setRetranslating] = useState(false)
|
||||||
|
const [translatePending, setTranslatePending] = useState(false)
|
||||||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||||
const [savingText, setSavingText] = useState(false)
|
const [savingText, setSavingText] = useState(false)
|
||||||
const [sourceLanguage, setSourceLanguage] = useState('')
|
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||||
|
|
||||||
|
// Description state
|
||||||
|
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||||||
|
const [editedDescription, setEditedDescription] = useState<string>('')
|
||||||
|
const [savingDesc, setSavingDesc] = useState(false)
|
||||||
|
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||||||
|
const [descPending, setDescPending] = useState(false)
|
||||||
|
const [descError, setDescError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// OCR settings
|
||||||
|
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
||||||
|
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||||
|
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||||
|
|
||||||
// Text overlay state
|
// Text overlay state
|
||||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
|
|
||||||
|
// Polling ref
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
@@ -42,18 +61,70 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||||
|
|
||||||
// Fetch existing AI fields on mount / item change
|
// Fetch existing AI fields on mount / item change
|
||||||
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 ?? '')
|
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()
|
||||||
@@ -72,24 +143,189 @@ 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}
|
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
|
||||||
</span>
|
<div className="relative flex-1 min-h-0 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
{/* Text overlay button — only shown when extracted text exists */}
|
<img
|
||||||
{extractedText && (
|
src={url}
|
||||||
|
alt={name}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Prev / Next */}
|
||||||
|
{onPrev && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNext && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text overlay */}
|
||||||
|
{showTextOverlay && displayText && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{extractedText && translatedText && (
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOriginal((v) => !v)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
>
|
||||||
|
{showOriginal ? 'Show Translation' : 'Show Original'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Floating controls ── */}
|
||||||
|
|
||||||
|
{/* Filename pill — bottom-left */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||||
|
>
|
||||||
|
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags + Close — top-right */}
|
||||||
|
<div
|
||||||
|
className="absolute top-4 right-4 flex items-center gap-1.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{itemKey && !showTags && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
|
aria-label="Show tags"
|
||||||
|
title="Tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!showTags && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
|
aria-label="Close"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text display button — bottom-right, hidden when panel open */}
|
||||||
|
{!showTags && extractedText && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center transition-colors"
|
className={`absolute bottom-4 right-4 ${smallBtn}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
||||||
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
||||||
@@ -103,208 +339,156 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||||||
title="Display text"
|
title="Display text"
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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="6" x2="21" y2="6"/>
|
||||||
<line x1="3" y1="12" x2="15" y2="12"/>
|
<line x1="3" y1="12" x2="15" y2="12"/>
|
||||||
<line x1="3" y1="18" x2="18" y2="18"/>
|
<line x1="3" y1="18" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTags ? (
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
|
{showTags && (
|
||||||
{/* Image */}
|
<MediaTagPanel
|
||||||
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
|
itemKey={itemKey!}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
onHide={() => setShowTags(false)}
|
||||||
<img
|
onClose={onClose}
|
||||||
src={url}
|
onTagsChanged={onTagsChanged}
|
||||||
alt={name}
|
onAiTag={readOnly ? undefined : onAiTag}
|
||||||
className="max-w-full max-h-full w-auto h-auto object-contain rounded-lg"
|
readOnly={readOnly}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
{onPrev && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Previous"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onNext && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Text overlay */}
|
|
||||||
{showTextOverlay && displayText && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{extractedText && translatedText && (
|
|
||||||
<div className="flex justify-end mb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowOriginal((v) => !v)}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
|
||||||
>
|
|
||||||
{showOriginal ? 'Show Translation' : 'Show Original'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
|
||||||
{displayText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* 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)' }}>
|
{/* Description section */}
|
||||||
Tags
|
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
</p>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
<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 */}
|
{/* Text extraction section — only for images */}
|
||||||
{isImage && (
|
{isImage && (
|
||||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center justify-between">
|
||||||
Text Extraction
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
</p>
|
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>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
onClick={async () => {
|
<button
|
||||||
setExtracting(true)
|
onClick={() => callExtract('tesseract')}
|
||||||
setExtractError(null)
|
disabled={extracting || extractPending}
|
||||||
try {
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
method: 'POST',
|
onMouseEnter={(e) => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (!extracting && !extractPending) {
|
||||||
body: JSON.stringify({ itemKey }),
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
})
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
|
||||||
}
|
}
|
||||||
if (res.status === 202) {
|
}}
|
||||||
setExtractError('Queued — check AI Integrations for progress')
|
onMouseLeave={(e) => {
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
return
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
}
|
}}
|
||||||
const result = await res.json()
|
>
|
||||||
setExtractedText(result.extractedText || null)
|
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||||
setEditedExtractedText(result.extractedText || '')
|
</button>
|
||||||
setTranslatedText(result.translatedText || null)
|
<input
|
||||||
} catch (err) {
|
type="text"
|
||||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
value={ocrLanguageInput}
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||||
} finally {
|
placeholder={defaultOcrLanguages}
|
||||||
setExtracting(false)
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
}
|
style={{
|
||||||
}}
|
backgroundColor: 'var(--background)',
|
||||||
disabled={extracting}
|
border: '1px solid var(--border)',
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2"
|
color: 'var(--text-primary)',
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
width: 120,
|
||||||
onMouseEnter={(e) => {
|
}}
|
||||||
if (!extracting) {
|
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
/>
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
</div>
|
||||||
}
|
|
||||||
}}
|
|
||||||
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 && (
|
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
|
||||||
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{extractedText && (
|
{extractedText && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -380,103 +564,59 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRetranslating(true)
|
setRetranslating(true)
|
||||||
|
setTranslatePending(false)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||||
})
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setTranslatePending(true)
|
||||||
|
startPolling(extractedText, translatedText, aiDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||||
}
|
}
|
||||||
if (res.status !== 202) {
|
const result = await res.json()
|
||||||
const result = await res.json()
|
setTranslatedText(result.translatedText || null)
|
||||||
setTranslatedText(result.translatedText || null)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setRetranslating(false)
|
setRetranslating(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={retranslating}
|
disabled={retranslating || translatePending}
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
style={{
|
||||||
|
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!retranslating) {
|
if (!retranslating && !translatePending) {
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
if (!translatePending) {
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{retranslating ? '⟳ Translating…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</MediaTagPanel>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={name}
|
|
||||||
className="max-w-full max-h-full object-contain rounded-lg"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
{onPrev && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Previous"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onNext && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Text overlay */}
|
|
||||||
{showTextOverlay && displayText && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{extractedText && translatedText && (
|
|
||||||
<div className="flex justify-end mb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowOriginal((v) => !v)}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
|
||||||
>
|
|
||||||
{showOriginal ? 'Show Translation' : 'Show Original'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
|
||||||
{displayText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,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,18 +23,21 @@ 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 [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)
|
||||||
@@ -83,6 +88,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
setDoomScrollLoading(false)
|
setDoomScrollLoading(false)
|
||||||
}, [currentPath])
|
}, [currentPath])
|
||||||
|
|
||||||
|
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
||||||
|
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||||
|
|
||||||
const fetchAssignments = useCallback(() => {
|
const fetchAssignments = useCallback(() => {
|
||||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -92,6 +100,16 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/ai-settings/ocr')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
||||||
|
setOcrMode(d.ocrMode)
|
||||||
|
setDefaultOcrLanguages(d.ocrLanguages)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
const fetchRecursive = useCallback(() => {
|
const fetchRecursive = useCallback(() => {
|
||||||
@@ -325,12 +343,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
<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
|
||||||
@@ -387,6 +413,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', {
|
||||||
@@ -401,7 +429,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)
|
||||||
@@ -420,7 +448,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(() => ({}))
|
||||||
@@ -454,15 +482,28 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTranslate={async (e) => {
|
onTranslate={async (e) => {
|
||||||
const itemKey = itemKeyFor(e)
|
if (e.type === 'directory') {
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
method: 'POST',
|
const res = await fetch('/api/ai-tagging/translate-bulk', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({ itemKey }),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
body: JSON.stringify({ libraryId, path: dirRel }),
|
||||||
if (!res.ok) {
|
})
|
||||||
const data = await res.json().catch(() => ({}))
|
if (!res.ok) {
|
||||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
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) => {
|
||||||
@@ -505,10 +546,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' },
|
||||||
@@ -529,10 +573,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' },
|
||||||
@@ -594,7 +641,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate }: { 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>; onTranslate?: (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'
|
||||||
@@ -615,6 +662,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
const [describeError, setDescribeError] = useState<string | null>(null)
|
const [describeError, setDescribeError] = useState<string | null>(null)
|
||||||
const [translating, setTranslating] = useState(false)
|
const [translating, setTranslating] = useState(false)
|
||||||
const [translateError, setTranslateError] = useState<string | null>(null)
|
const [translateError, setTranslateError] = useState<string | null>(null)
|
||||||
|
const [showOcrPrompt, setShowOcrPrompt] = useState(false)
|
||||||
|
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
@@ -729,7 +778,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) }}
|
||||||
@@ -804,16 +853,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"
|
||||||
@@ -824,6 +878,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) => {
|
||||||
@@ -844,7 +949,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
🔍 Extract Text for Folder
|
🔍 Extract Text for Folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onTranslate && entry.mediaType === 'image' && (
|
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -861,7 +966,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
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')}
|
||||||
>
|
>
|
||||||
🌐 Translate
|
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onRename && (
|
{onRename && (
|
||||||
|
|||||||
@@ -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,18 +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(false)
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -45,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}
|
||||||
@@ -139,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"
|
||||||
>
|
>
|
||||||
@@ -202,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"
|
||||||
>
|
>
|
||||||
@@ -210,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { isBrowserPlayable } from '@/lib/browser-media'
|
|||||||
|
|
||||||
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)
|
||||||
@@ -20,7 +21,9 @@ export default function MoviesView({ 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 [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[]>([])
|
||||||
|
|
||||||
@@ -201,6 +204,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
src/components/tags/MediaTagPanel.tsx
Normal file
138
src/components/tags/MediaTagPanel.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } 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 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
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Tags section heading + optional AI button */}
|
||||||
|
<div className="flex items-center justify-between mt-4 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: [],
|
||||||
@@ -210,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">
|
||||||
@@ -274,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)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -299,7 +305,7 @@ 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] ?? ''
|
||||||
@@ -528,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) => {
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
|||||||
|
|
||||||
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[]>([])
|
||||||
@@ -31,7 +34,11 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
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 [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 +55,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 +99,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 +109,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 +146,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 +186,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,6 +341,40 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
const filteredSeries = series.filter((s) => {
|
const filteredSeries = series.filter((s) => {
|
||||||
@@ -336,6 +399,28 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
if (playingEpisode && playingEpisodeIndex !== null) {
|
if (playingEpisode && playingEpisodeIndex !== null) {
|
||||||
@@ -350,6 +435,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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -502,9 +588,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 +835,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 +914,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 +950,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 +966,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 +997,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface AiJobRow {
|
|||||||
started_at: number | null
|
started_at: number | null
|
||||||
completed_at: number | null
|
completed_at: number | null
|
||||||
item_title: string | null
|
item_title: string | null
|
||||||
|
payload: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToJob(row: AiJobRow): AiJob {
|
function rowToJob(row: AiJobRow): AiJob {
|
||||||
@@ -75,6 +76,7 @@ export function enqueueJob(
|
|||||||
jobType: AiJobType,
|
jobType: AiJobType,
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
sourceLanguage?: string,
|
sourceLanguage?: string,
|
||||||
|
payload?: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
||||||
@@ -96,9 +98,9 @@ export function enqueueJob(
|
|||||||
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
|
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title, payload)
|
||||||
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
|
||||||
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
|
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
|
||||||
|
|
||||||
// Wake the processor
|
// Wake the processor
|
||||||
wakeProcessor()
|
wakeProcessor()
|
||||||
@@ -251,6 +253,8 @@ async function processNextJob(): Promise<boolean> {
|
|||||||
|
|
||||||
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
||||||
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
||||||
|
// Parse job payload (carries per-call overrides, e.g. ocrLanguages for extract jobs)
|
||||||
|
const jobPayload = row.payload ? (JSON.parse(row.payload) as Record<string, string>) : null
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
||||||
@@ -265,7 +269,7 @@ async function processNextJob(): Promise<boolean> {
|
|||||||
await generateItemDescription(row.item_key)
|
await generateItemDescription(row.item_key)
|
||||||
break
|
break
|
||||||
case 'extract':
|
case 'extract':
|
||||||
await extractItemText(row.item_key)
|
await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
|
||||||
break
|
break
|
||||||
case 'translate':
|
case 'translate':
|
||||||
await translateItemText(row.item_key, sourceLanguage || undefined)
|
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Library, Tag, TagCategory } from '@/types'
|
|||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
|
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
|
||||||
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
|
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
|
||||||
import { getAiImagePath, 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'
|
||||||
|
|
||||||
@@ -509,18 +509,38 @@ 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.
|
||||||
|
* Returns the extracted text and a mean confidence score (0–100).
|
||||||
|
* A confidence of 0 with empty text means no recognisable text was found.
|
||||||
|
*/
|
||||||
|
async function extractWithTesseract(
|
||||||
|
imagePath: string,
|
||||||
|
languages: string,
|
||||||
|
): Promise<{ text: string; confidence: number }> {
|
||||||
|
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.
|
* Only works for images in mixed libraries.
|
||||||
* Translation is not performed automatically — call translateItemText() separately.
|
* Translation is not performed automatically — call translateItemText() separately.
|
||||||
* Returns { extractedText, translatedText } where translatedText is always null.
|
* Returns { extractedText, translatedText } where translatedText is always null.
|
||||||
*/
|
*/
|
||||||
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const config = getEffectiveAiConfig(libraryId)
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const extractModel = config.modelExtract || config.model
|
|
||||||
if (!config.endpoint || !extractModel) {
|
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
@@ -547,12 +567,41 @@ 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 { ocrMode: configOcrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
|
||||||
|
const ocrMode = ocrModeOverride ?? configOcrMode
|
||||||
|
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
|
||||||
|
|
||||||
|
// ── Tesseract path ────────────────────────────────────────────────────────
|
||||||
|
if (ocrMode === 'tesseract' || ocrMode === 'hybrid') {
|
||||||
|
const ocrImagePath = await getOcrImagePath(resolvedMedia.path, libraryId)
|
||||||
|
const { text, confidence } = await extractWithTesseract(ocrImagePath, ocrLanguages)
|
||||||
|
|
||||||
|
const useTesseractResult = ocrMode === 'tesseract' || confidence >= ocrConfidenceThreshold
|
||||||
|
if (useTesseractResult) {
|
||||||
|
console.log(`[ocr] tesseract used for ${itemKey} (confidence=${confidence}, mode=${ocrMode})`)
|
||||||
|
if (!text) {
|
||||||
|
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
|
||||||
|
return { extractedText: '', translatedText: null }
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(text, itemKey)
|
||||||
|
return { extractedText: text, translatedText: null }
|
||||||
|
}
|
||||||
|
console.log(`[ocr] tesseract confidence too low (${confidence} < ${ocrConfidenceThreshold}), falling back to LLM for ${itemKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LLM vision path ───────────────────────────────────────────────────────
|
||||||
|
const extractModel = config.modelExtract || config.model
|
||||||
|
if (!config.endpoint || !extractModel) {
|
||||||
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
}
|
||||||
|
|
||||||
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
||||||
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
|
|
||||||
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
|
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
|
||||||
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
|
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
|
||||||
|
|
||||||
|
console.log(`[ocr] llm used for ${itemKey} (mode=${ocrMode})`)
|
||||||
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
|
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
|
||||||
|
|
||||||
if (!extractedText || extractedText === '[NO TEXT]') {
|
if (!extractedText || extractedText === '[NO TEXT]') {
|
||||||
@@ -607,6 +656,14 @@ export function updateExtractedText(itemKey: string, text: string): void {
|
|||||||
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the ai_description of an item.
|
||||||
|
*/
|
||||||
|
export function updateAiDescription(itemKey: string, description: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate text to a target language using the chat API.
|
* Translate text to a target language using the chat API.
|
||||||
* Returns null if the text is already in the target language.
|
* Returns null if the text is already in the target language.
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const DEFAULT_PROMPT_EXTRACT =
|
|||||||
'Be mindful of different colors of text that may indicate different speakers or emphasis.'
|
'Be mindful of different colors of text that may indicate different speakers or emphasis.'
|
||||||
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
|
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
|
||||||
|
|
||||||
|
export type OcrMode = 'hybrid' | 'tesseract' | 'llm'
|
||||||
|
|
||||||
export interface AiConfig {
|
export interface AiConfig {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
model: string
|
model: string
|
||||||
@@ -62,6 +64,9 @@ export interface AiConfig {
|
|||||||
maxTokensDescribe: number
|
maxTokensDescribe: number
|
||||||
maxTokensExtract: number
|
maxTokensExtract: number
|
||||||
maxTokensTranslate: number
|
maxTokensTranslate: number
|
||||||
|
ocrMode: OcrMode
|
||||||
|
ocrLanguages: string
|
||||||
|
ocrConfidenceThreshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAiConfig(): AiConfig {
|
export function getAiConfig(): AiConfig {
|
||||||
@@ -84,10 +89,15 @@ export function getAiConfig(): AiConfig {
|
|||||||
const maxTokensDescribe = parseInt(getSetting('ai_max_tokens_describe') ?? '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 maxTokensExtract = parseInt(getSetting('ai_max_tokens_extract') ?? '8192', 10) || 8192
|
||||||
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
|
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
|
||||||
|
const rawOcrMode = getSetting('ai_ocr_mode') ?? 'hybrid'
|
||||||
|
const ocrMode: OcrMode = rawOcrMode === 'tesseract' || rawOcrMode === 'llm' ? rawOcrMode : 'hybrid'
|
||||||
|
const ocrLanguages = getSetting('ai_ocr_languages') ?? 'eng'
|
||||||
|
const ocrConfidenceThreshold = parseInt(getSetting('ai_ocr_confidence_threshold') ?? '70', 10) || 70
|
||||||
return {
|
return {
|
||||||
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
|
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
|
||||||
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
||||||
|
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +117,9 @@ export function updateAiConfig(
|
|||||||
maxTokensDescribe?: number,
|
maxTokensDescribe?: number,
|
||||||
maxTokensExtract?: number,
|
maxTokensExtract?: number,
|
||||||
maxTokensTranslate?: 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)
|
||||||
@@ -123,6 +136,9 @@ export function updateAiConfig(
|
|||||||
if (maxTokensDescribe !== undefined) setSetting('ai_max_tokens_describe', String(Math.max(1, Math.floor(maxTokensDescribe))))
|
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 (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 (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 {
|
||||||
@@ -249,6 +265,9 @@ export function getEffectiveAiConfig(libraryId: string): AiConfig {
|
|||||||
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
|
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
|
||||||
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
|
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
|
||||||
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
|
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
|
||||||
|
ocrMode: global.ocrMode,
|
||||||
|
ocrLanguages: global.ocrLanguages,
|
||||||
|
ocrConfidenceThreshold: global.ocrConfidenceThreshold,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth guard result type
|
// Auth guard result type
|
||||||
type AuthSuccess = { session: IronSession<SessionData> }
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateMediaItemsAiFields(db)
|
migrateMediaItemsAiFields(db)
|
||||||
migrateLibraryAiSettings(db)
|
migrateLibraryAiSettings(db)
|
||||||
migrateAiJobs(db)
|
migrateAiJobs(db)
|
||||||
|
migrateLibraryPermissionsAccessLevel(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +319,15 @@ function migrateLibrariesType(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (row && !row.sql.includes('access_level')) {
|
||||||
|
db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function migrateAiJobs(db: Database.Database): void {
|
function migrateAiJobs(db: Database.Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS ai_jobs (
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
||||||
@@ -338,4 +348,12 @@ function migrateAiJobs(db: Database.Database): void {
|
|||||||
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
||||||
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Add payload column if not present
|
||||||
|
const aiJobsRow = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (aiJobsRow && !aiJobsRow.sql.includes('payload')) {
|
||||||
|
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ async function generateAiImage(src: string, dest: string): Promise<void> {
|
|||||||
fs.renameSync(tmp, dest)
|
fs.renameSync(tmp, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate a grayscale, contrast-normalised PNG for local OCR (Tesseract).
|
||||||
|
* PNG is lossless and avoids JPEG artefacts that can degrade OCR accuracy. */
|
||||||
|
async function generateOcrImage(src: string, dest: string): Promise<void> {
|
||||||
|
const tmp = dest + '.tmp'
|
||||||
|
await sharp(src)
|
||||||
|
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
|
||||||
|
.grayscale()
|
||||||
|
.normalise()
|
||||||
|
.png()
|
||||||
|
.toFile(tmp)
|
||||||
|
fs.renameSync(tmp, dest)
|
||||||
|
}
|
||||||
|
|
||||||
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
|
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
|
||||||
function run(bin: string, args: string[]): Promise<void> {
|
function run(bin: string, args: string[]): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -190,6 +203,24 @@ export async function getAiImagePath(
|
|||||||
return cacheFile
|
return cacheFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute path to a preprocessed PNG suitable for local OCR.
|
||||||
|
* The image is converted to grayscale and contrast-normalised for better
|
||||||
|
* Tesseract accuracy. Cached with an `_ocr` suffix.
|
||||||
|
*/
|
||||||
|
export async function getOcrImagePath(
|
||||||
|
absoluteFilePath: string,
|
||||||
|
libraryId: string
|
||||||
|
): Promise<string> {
|
||||||
|
ensureCacheDir()
|
||||||
|
const key = cacheKey(libraryId, absoluteFilePath)
|
||||||
|
const cacheFile = path.join(CACHE_DIR, key + '_ocr.png')
|
||||||
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||||
|
if (cached) return cached
|
||||||
|
await generateOcrImage(absoluteFilePath, cacheFile)
|
||||||
|
return cacheFile
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,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'
|
||||||
@@ -44,6 +45,7 @@ export interface FileEntry {
|
|||||||
mediaType: MediaType | null
|
mediaType: MediaType | null
|
||||||
url: string | null
|
url: string | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
|
hasExtractedText?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Movie {
|
export interface Movie {
|
||||||
|
|||||||
Reference in New Issue
Block a user