Compare commits
48 Commits
732e9134c3
...
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 | ||
|
|
1350a6f94b | ||
|
|
2fc9a34626 | ||
| 236f168eeb | |||
|
|
fea55594d0 | ||
|
|
8557c80c52 | ||
|
|
68b1ed94ea | ||
|
|
e31a9667ef | ||
| c454d020da | |||
|
|
b0fc275a52 | ||
|
|
cd9a83ea90 | ||
|
|
5ba73b2e56 | ||
| 2b51f72f96 | |||
|
|
efaff8ca1b | ||
|
|
89ac22e9d1 | ||
|
|
b0d146679f | ||
|
|
887cc05901 | ||
| afb9540df2 | |||
|
|
5ac4b3bd8a | ||
|
|
470f34c985 | ||
|
|
7e284383b4 | ||
| 60790a3af1 | |||
|
|
6c769b457f | ||
|
|
ad9920a448 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ medialore.db-wal
|
|||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.session_secret
|
.session_secret
|
||||||
.vscode/
|
.vscode/
|
||||||
|
*.traineddata
|
||||||
@@ -45,6 +45,11 @@ COPY --from=builder /app/.next/static ./.next/static
|
|||||||
COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||||
COPY --from=deps /app/node_modules/sharp ./node_modules/sharp
|
COPY --from=deps /app/node_modules/sharp ./node_modules/sharp
|
||||||
COPY --from=deps /app/node_modules/@img ./node_modules/@img
|
COPY --from=deps /app/node_modules/@img ./node_modules/@img
|
||||||
|
# tesseract.js loads its worker via worker_threads using a runtime-constructed path,
|
||||||
|
# so the standalone file tracer never discovers src/worker-script/node/. Copy the
|
||||||
|
# full package so that path resolves correctly at runtime.
|
||||||
|
COPY --from=deps /app/node_modules/tesseract.js ./node_modules/tesseract.js
|
||||||
|
COPY --from=deps /app/node_modules/tesseract.js-core ./node_modules/tesseract.js-core
|
||||||
|
|
||||||
# Create thumbnail cache directory (mounted as a volume in production)
|
# Create thumbnail cache directory (mounted as a volume in production)
|
||||||
RUN mkdir -p /app/.thumbnails
|
RUN mkdir -p /app/.thumbnails
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
serverExternalPackages: ['better-sqlite3', 'sharp'],
|
serverExternalPackages: ['better-sqlite3', 'sharp', 'tesseract.js'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
63
src/app/api/ai-jobs/route.ts
Normal file
63
src/app/api/ai-jobs/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getJobQueue, getJobHistory, retryJob, cancelJob, cancelAllQueued, clearJobHistory } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const queue = getJobQueue()
|
||||||
|
const history = getJobHistory(50)
|
||||||
|
return NextResponse.json({ queue, history })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
let body: { action?: string; jobId?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, jobId } = body
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'retry': {
|
||||||
|
if (!jobId || typeof jobId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const ok = retryJob(jobId)
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Job not found or not in failed state' }, { status: 404 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel': {
|
||||||
|
if (!jobId || typeof jobId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const ok = cancelJob(jobId)
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Job not found or not in queued state' }, { status: 404 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel-all': {
|
||||||
|
const cancelled = cancelAllQueued()
|
||||||
|
return NextResponse.json({ cancelled })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'clear-history': {
|
||||||
|
const cleared = clearJobHistory()
|
||||||
|
return NextResponse.json({ cleared })
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/api/ai-settings/library/[id]/route.ts
Normal file
48
src/app/api/ai-settings/library/[id]/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getLibraryAiOverrides, setLibraryAiOverrides } from '@/lib/app-settings'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
return NextResponse.json(getLibraryAiOverrides(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
let body: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
setLibraryAiOverrides(id, {
|
||||||
|
modelTagging: typeof body.modelTagging === 'string' ? body.modelTagging : undefined,
|
||||||
|
modelDescribe: typeof body.modelDescribe === 'string' ? body.modelDescribe : undefined,
|
||||||
|
modelExtract: typeof body.modelExtract === 'string' ? body.modelExtract : undefined,
|
||||||
|
modelTranslate: typeof body.modelTranslate === 'string' ? body.modelTranslate : undefined,
|
||||||
|
promptDescribe: typeof body.promptDescribe === 'string' ? body.promptDescribe : undefined,
|
||||||
|
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
|
||||||
|
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
|
||||||
|
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
|
||||||
|
maxTokensTag: typeof body.maxTokensTag === 'number' ? body.maxTokensTag : (body.maxTokensTag === null ? null : undefined),
|
||||||
|
maxTokensDescribe: typeof body.maxTokensDescribe === 'number' ? body.maxTokensDescribe : (body.maxTokensDescribe === null ? null : undefined),
|
||||||
|
maxTokensExtract: typeof body.maxTokensExtract === 'number' ? body.maxTokensExtract : (body.maxTokensExtract === null ? null : undefined),
|
||||||
|
maxTokensTranslate: typeof body.maxTokensTranslate === 'number' ? body.maxTokensTranslate : (body.maxTokensTranslate === null ? null : undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(getLibraryAiOverrides(id))
|
||||||
|
}
|
||||||
11
src/app/api/ai-settings/ocr/route.ts
Normal file
11
src/app/api/ai-settings/ocr/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getAiConfig } from '@/lib/app-settings'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { ocrMode, ocrLanguages } = getAiConfig()
|
||||||
|
return NextResponse.json({ ocrMode, ocrLanguages })
|
||||||
|
}
|
||||||
@@ -1,27 +1,57 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
import { getAiConfig, updateAiConfig } from '@/lib/app-settings'
|
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries, type OcrMode } from '@/lib/app-settings'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const auth = await requireAdmin(request)
|
const auth = await requireAdmin(request)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { endpoint, model, enabled } = getAiConfig()
|
const config = getAiConfig()
|
||||||
return NextResponse.json({ endpoint, model, enabled })
|
const preferredLanguage = getPreferredLanguage()
|
||||||
|
const maxRetries = getAiMaxRetries()
|
||||||
|
return NextResponse.json({ ...config, preferredLanguage, maxRetries })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
const auth = await requireAdmin(request)
|
const auth = await requireAdmin(request)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
let body: { endpoint?: string; model?: string; enabled?: boolean }
|
let body: {
|
||||||
|
endpoint?: string
|
||||||
|
model?: string
|
||||||
|
modelTagging?: string
|
||||||
|
modelDescribe?: string
|
||||||
|
modelExtract?: string
|
||||||
|
modelTranslate?: string
|
||||||
|
enabled?: boolean
|
||||||
|
preferredLanguage?: string
|
||||||
|
promptDescribe?: string
|
||||||
|
promptTagger?: string
|
||||||
|
promptExtract?: string
|
||||||
|
promptTranslate?: string
|
||||||
|
maxRetries?: number
|
||||||
|
maxTokensTag?: number
|
||||||
|
maxTokensDescribe?: number
|
||||||
|
maxTokensExtract?: number
|
||||||
|
maxTokensTranslate?: number
|
||||||
|
ocrMode?: string
|
||||||
|
ocrLanguages?: string
|
||||||
|
ocrConfidenceThreshold?: number
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { endpoint, model, enabled } = body
|
const {
|
||||||
|
endpoint, model, enabled, preferredLanguage,
|
||||||
|
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
||||||
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
|
maxRetries,
|
||||||
|
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
||||||
|
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
||||||
|
} = body
|
||||||
|
|
||||||
if (typeof endpoint !== 'string') {
|
if (typeof endpoint !== 'string') {
|
||||||
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
|
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
|
||||||
@@ -33,6 +63,35 @@ export async function PUT(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 })
|
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAiConfig(endpoint, model, enabled)
|
updateAiConfig(
|
||||||
return NextResponse.json({ endpoint, model, enabled })
|
endpoint,
|
||||||
|
model,
|
||||||
|
enabled,
|
||||||
|
typeof modelTagging === 'string' ? modelTagging : undefined,
|
||||||
|
typeof modelDescribe === 'string' ? modelDescribe : undefined,
|
||||||
|
typeof modelExtract === 'string' ? modelExtract : undefined,
|
||||||
|
typeof modelTranslate === 'string' ? modelTranslate : undefined,
|
||||||
|
typeof promptDescribe === 'string' ? promptDescribe : undefined,
|
||||||
|
typeof promptTagger === 'string' ? promptTagger : undefined,
|
||||||
|
typeof promptExtract === 'string' ? promptExtract : undefined,
|
||||||
|
typeof promptTranslate === 'string' ? promptTranslate : undefined,
|
||||||
|
typeof maxTokensTag === 'number' ? maxTokensTag : undefined,
|
||||||
|
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
|
||||||
|
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
|
||||||
|
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
|
||||||
|
(ocrMode === 'hybrid' || ocrMode === 'tesseract' || ocrMode === 'llm') ? (ocrMode as OcrMode) : undefined,
|
||||||
|
typeof ocrLanguages === 'string' ? ocrLanguages : undefined,
|
||||||
|
typeof ocrConfidenceThreshold === 'number' ? ocrConfidenceThreshold : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
|
||||||
|
setPreferredLanguage(preferredLanguage.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maxRetries === 'number' && Number.isFinite(maxRetries)) {
|
||||||
|
setAiMaxRetries(maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getAiConfig()
|
||||||
|
return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage(), maxRetries: getAiMaxRetries() })
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/app/api/ai-tagging/describe-bulk/route.ts
Normal file
27
src/app/api/ai-tagging/describe-bulk/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg'])
|
||||||
|
const MEDIA_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS])
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
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 jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
||||||
|
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||||
|
}
|
||||||
24
src/app/api/ai-tagging/describe/route.ts
Normal file
24
src/app/api/ai-tagging/describe/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: { itemKey?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemKey } = body
|
||||||
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
||||||
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
|
}
|
||||||
25
src/app/api/ai-tagging/extract-text-bulk/route.ts
Normal file
25
src/app/api/ai-tagging/extract-text-bulk/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
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 jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
||||||
|
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||||
|
}
|
||||||
33
src/app/api/ai-tagging/extract-text/route.ts
Normal file
33
src/app/api/ai-tagging/extract-text/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemKey, ocrLanguages, ocrMode } = body
|
||||||
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
55
src/app/api/ai-tagging/fields/route.ts
Normal file
55
src/app/api/ai-tagging/fields/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const itemKey = searchParams.get('itemKey')
|
||||||
|
|
||||||
|
if (!itemKey) {
|
||||||
|
return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const fields = getAiFields(itemKey)
|
||||||
|
return NextResponse.json(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemKey, extractedText, aiDescription } = body
|
||||||
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (extractedText === undefined && aiDescription === undefined) {
|
||||||
|
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
if (extractedText !== undefined) {
|
||||||
|
if (typeof extractedText !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'extractedText must be a string' }, { status: 400 })
|
||||||
|
}
|
||||||
|
updateExtractedText(itemKey, extractedText)
|
||||||
|
}
|
||||||
|
if (aiDescription !== undefined) {
|
||||||
|
if (typeof aiDescription !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'aiDescription must be a string' }, { status: 400 })
|
||||||
|
}
|
||||||
|
updateAiDescription(itemKey, aiDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { tagSingleItem } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string }
|
||||||
@@ -16,24 +16,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
||||||
const tagIds = await tagSingleItem(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json({ tagIds })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NO_IMAGE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging] Error tagging item:', error)
|
|
||||||
return NextResponse.json({ error: 'AI tagging failed' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal file
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: { libraryId?: string; path?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { libraryId, path: dirPath } = body
|
||||||
|
if (!libraryId || typeof libraryId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const prefix = dirPath
|
||||||
|
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
|
||||||
|
: `${libraryId}:mixed_file:`
|
||||||
|
|
||||||
|
// Only enqueue translate jobs for items that already have extracted text
|
||||||
|
const items = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = ? AND extracted_text IS NOT NULL'
|
||||||
|
)
|
||||||
|
.all(`${prefix}%`, 'mixed_file') as { item_key: string }[]
|
||||||
|
|
||||||
|
const jobIds = items.map(({ item_key }) => enqueueJob(item_key, 'translate', libraryId))
|
||||||
|
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||||
|
}
|
||||||
24
src/app/api/ai-tagging/translate/route.ts
Normal file
24
src/app/api/ai-tagging/translate/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: { itemKey?: string; sourceLanguage?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemKey, sourceLanguage } = body
|
||||||
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
||||||
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -216,32 +216,39 @@ function UserRow({
|
|||||||
|
|
||||||
// ─── Permissions Panel ────────────────────────────────────────────────────────
|
// ─── Permissions Panel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AccessLevel = 'none' | 'read' | 'write'
|
||||||
|
|
||||||
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
|
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
|
||||||
const [permitted, setPermitted] = useState<string[]>([])
|
const [levels, setLevels] = useState<Record<string, AccessLevel>>({})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
|
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { libraryIds: string[] }) => {
|
.then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => {
|
||||||
setPermitted(data.libraryIds)
|
const map: Record<string, AccessLevel> = {}
|
||||||
|
for (const p of data.permissions) {
|
||||||
|
map[p.libraryId] = p.accessLevel
|
||||||
|
}
|
||||||
|
setLevels(map)
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
})
|
})
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
const toggle = (libraryId: string) => {
|
const setLevel = (libraryId: string, level: AccessLevel) => {
|
||||||
setPermitted((prev) =>
|
setLevels((prev) => ({ ...prev, [libraryId]: level }))
|
||||||
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
const permissions = Object.entries(levels)
|
||||||
|
.filter(([, level]) => level !== 'none')
|
||||||
|
.map(([libraryId, accessLevel]) => ({ libraryId, accessLevel }))
|
||||||
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
|
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ libraryIds: permitted }),
|
body: JSON.stringify({ permissions }),
|
||||||
})
|
})
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -265,23 +272,40 @@ function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Li
|
|||||||
{libraries.length === 0 ? (
|
{libraries.length === 0 ? (
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => {
|
||||||
<label key={lib.id} className="flex items-center gap-2 cursor-pointer">
|
const current = levels[lib.id] ?? 'none'
|
||||||
<input
|
return (
|
||||||
type="checkbox"
|
<div key={lib.id} className="flex items-center justify-between gap-3">
|
||||||
checked={permitted.includes(lib.id)}
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
onChange={() => toggle(lib.id)}
|
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
className="rounded"
|
{lib.name}
|
||||||
/>
|
</span>
|
||||||
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{lib.name}
|
({lib.type})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
</div>
|
||||||
({lib.type})
|
<div
|
||||||
</span>
|
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
|
||||||
</label>
|
style={{ border: '1px solid var(--border)' }}
|
||||||
))}
|
>
|
||||||
|
{(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => (
|
||||||
|
<button
|
||||||
|
key={lvl}
|
||||||
|
onClick={() => setLevel(lib.id, lvl)}
|
||||||
|
className="px-2.5 py-1 transition-colors capitalize"
|
||||||
|
style={{
|
||||||
|
backgroundColor: current === lvl ? 'var(--accent)' : 'transparent',
|
||||||
|
color: current === lvl ? 'var(--background)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lvl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Props {
|
|||||||
items: DoomScrollItem[]
|
items: DoomScrollItem[]
|
||||||
videoContext?: 'mixed' | 'movies' | 'tv'
|
videoContext?: 'mixed' | 'movies' | 'tv'
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onViewInLibrary?: (item: DoomScrollItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_CAP = 100
|
const HISTORY_CAP = 100
|
||||||
@@ -26,7 +27,7 @@ function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): D
|
|||||||
return pool[Math.floor(Math.random() * pool.length)]
|
return pool[Math.floor(Math.random() * pool.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DoomScrollView({ items, videoContext = 'mixed', onClose }: Props) {
|
export default function DoomScrollView({ items, videoContext = 'mixed', onClose, onViewInLibrary }: Props) {
|
||||||
const settings = useUserSettings()
|
const settings = useUserSettings()
|
||||||
const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted
|
const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted
|
||||||
|
|
||||||
@@ -40,7 +41,17 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
|
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
|
||||||
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
|
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
|
||||||
|
|
||||||
|
// Text overlay state
|
||||||
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
|
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||||
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
|
const [extractPending, setExtractPending] = useState(false)
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const cooldownRef = useRef(false)
|
const cooldownRef = useRef(false)
|
||||||
const touchStartY = useRef<number | null>(null)
|
const touchStartY = useRef<number | null>(null)
|
||||||
|
|
||||||
@@ -48,6 +59,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
const isVideo = current?.mediaType === 'video'
|
const isVideo = current?.mediaType === 'video'
|
||||||
const backCount = history.length - 1 - historyIndex
|
const backCount = history.length - 1 - historyIndex
|
||||||
|
|
||||||
|
// Derived: what text to display in the overlay
|
||||||
|
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
const goNext = useCallback(() => {
|
||||||
if (items.length === 0) return
|
if (items.length === 0) return
|
||||||
setHistoryIndex((idx) => {
|
setHistoryIndex((idx) => {
|
||||||
@@ -114,11 +128,44 @@ 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; clear any in-flight poll on item change
|
||||||
|
useEffect(() => {
|
||||||
|
if (extractPollRef.current) {
|
||||||
|
clearInterval(extractPollRef.current)
|
||||||
|
extractPollRef.current = null
|
||||||
|
}
|
||||||
|
setExtractedText(null)
|
||||||
|
setTranslatedText(null)
|
||||||
|
setShowTextOverlay(false)
|
||||||
|
setShowOriginal(false)
|
||||||
|
setExtracting(false)
|
||||||
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
|
if (!current?.itemKey) return
|
||||||
|
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||||
|
setExtractedText(data.extractedText)
|
||||||
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [current?.itemKey])
|
||||||
|
|
||||||
|
// Clean up poll on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') { onClose(); return }
|
if (e.key === 'Escape') { onClose(); return }
|
||||||
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') }
|
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') }
|
||||||
if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') }
|
if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') }
|
||||||
|
if (e.key === 't' || e.key === 'T') {
|
||||||
|
if (extractedText) setShowTextOverlay((v) => !v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -147,7 +194,59 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
document.removeEventListener('touchend', handleTouchEnd)
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [navigate, onClose])
|
}, [navigate, onClose, extractedText])
|
||||||
|
|
||||||
|
const handleExtractText = async () => {
|
||||||
|
if (!current?.itemKey) return
|
||||||
|
const itemKey = current.itemKey
|
||||||
|
setExtracting(true)
|
||||||
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey }),
|
||||||
|
})
|
||||||
|
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) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
setExtractedText(result.extractedText || null)
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
if (result.extractedText) setShowTextOverlay(true)
|
||||||
|
} catch (err) {
|
||||||
|
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
|
||||||
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setExtracting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
||||||
@@ -219,8 +318,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
loop={!autoPlayEnabled}
|
loop={!autoPlayEnabled}
|
||||||
muted={localMuted}
|
muted={localMuted}
|
||||||
playsInline
|
playsInline
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain cursor-pointer"
|
||||||
style={{ backgroundColor: '#000' }}
|
style={{ backgroundColor: '#000' }}
|
||||||
|
onClick={() => setIsPaused((v) => !v)}
|
||||||
/>
|
/>
|
||||||
) : current?.mediaType === 'image' ? (
|
) : current?.mediaType === 'image' ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -233,32 +333,116 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar: mute | filename | play-pause */}
|
{/* Text overlay */}
|
||||||
|
{showTextOverlay && displayText && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{extractedText && translatedText && (
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOriginal((v) => !v)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
>
|
||||||
|
{showOriginal ? 'Show Translation' : 'Show Original'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom bar: mute | filename | action buttons */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
||||||
<div className="w-9 flex-shrink-0">
|
<div className="w-9 flex-shrink-0">
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLocalMuted((v) => !v)}
|
onClick={() => setLocalMuted((v) => !v)}
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-base transition-opacity hover:opacity-100 opacity-70"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||||
aria-label={localMuted ? 'Unmute' : 'Mute'}
|
aria-label={localMuted ? 'Unmute' : 'Mute'}
|
||||||
>
|
>
|
||||||
{localMuted ? '🔇' : '🔊'}
|
{localMuted ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||||
|
<line x1="23" y1="9" x2="17" y2="15"/>
|
||||||
|
<line x1="17" y1="9" x2="23" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||||||
{current?.name}
|
{current?.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-9 flex-shrink-0 flex justify-end">
|
<div className="flex-shrink-0 flex items-center gap-1">
|
||||||
{isVideo && (
|
{extractedText ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsPaused((v) => !v)}
|
onClick={() => setShowTextOverlay((v) => !v)}
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-sm transition-opacity hover:opacity-100 opacity-70"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
style={{
|
||||||
aria-label={isPaused ? 'Play' : 'Pause'}
|
backgroundColor: showTextOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||||||
>
|
>
|
||||||
{isPaused ? '▶' : '⏸'}
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="15" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||||
|
<button
|
||||||
|
onClick={handleExtractText}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
backgroundColor: extractPending
|
||||||
|
? 'var(--accent)'
|
||||||
|
: extractError
|
||||||
|
? 'rgba(127,29,29,0.8)'
|
||||||
|
: 'rgba(0,0,0,0.5)',
|
||||||
|
color: extractError ? '#fca5a5' : '#fff',
|
||||||
|
}}
|
||||||
|
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
||||||
|
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
||||||
|
>
|
||||||
|
{extracting || extractPending ? (
|
||||||
|
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{onViewInLibrary && current?.itemKey && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onViewInLibrary(current) }}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||||
|
aria-label="View in library"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const TABS = [
|
|||||||
{ href: '/manage/tags', label: 'Tags' },
|
{ href: '/manage/tags', label: 'Tags' },
|
||||||
{ href: '/manage/users', label: 'Users' },
|
{ href: '/manage/users', label: 'Users' },
|
||||||
{ href: '/manage/scanning', label: 'Scanning' },
|
{ href: '/manage/scanning', label: 'Scanning' },
|
||||||
{ href: '/manage/ai-tagging', label: 'AI Tagging' },
|
{ href: '/manage/ai-tagging', label: 'AI Integrations' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function ManageSubNav() {
|
export default function ManageSubNav() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,15 +12,118 @@ interface Props {
|
|||||||
itemKey?: string
|
itemKey?: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onAiTag?: () => Promise<void>
|
onAiTag?: () => Promise<void>
|
||||||
|
showTags?: boolean
|
||||||
|
onShowTagsChange?: (v: boolean) => void
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) {
|
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTags, setShowTags] = useState(
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
)
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
// Text extraction state
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
|
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||||
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
const [extractPending, setExtractPending] = useState(false)
|
||||||
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
|
const [retranslating, setRetranslating] = useState(false)
|
||||||
|
const [translatePending, setTranslatePending] = useState(false)
|
||||||
|
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||||
|
const [savingText, setSavingText] = useState(false)
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||||
|
|
||||||
|
// Description state
|
||||||
|
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||||||
|
const [editedDescription, setEditedDescription] = useState<string>('')
|
||||||
|
const [savingDesc, setSavingDesc] = useState(false)
|
||||||
|
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||||||
|
const [descPending, setDescPending] = useState(false)
|
||||||
|
const [descError, setDescError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// OCR settings
|
||||||
|
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
||||||
|
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||||
|
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||||
|
|
||||||
|
// Text overlay state
|
||||||
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
|
|
||||||
|
// Polling ref
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// Determine if this is an image file (for text extraction controls)
|
||||||
|
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
||||||
|
|
||||||
|
// Derived: what text to display in the overlay
|
||||||
|
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||||
|
|
||||||
|
// Fetch existing AI fields on mount / item change
|
||||||
|
const fetchAiFields = useCallback(() => {
|
||||||
|
if (!itemKey) return Promise.resolve()
|
||||||
|
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
|
||||||
|
setExtractedText(data.extractedText)
|
||||||
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
|
setAiDescription(data.aiDescription)
|
||||||
|
setEditedDescription(data.aiDescription ?? '')
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [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) => {
|
||||||
@@ -40,143 +143,94 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGenerateDescription = async () => {
|
||||||
|
if (!itemKey) return
|
||||||
|
setGeneratingDesc(true)
|
||||||
|
setDescError(null)
|
||||||
|
setDescPending(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/describe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey }),
|
||||||
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setDescPending(true)
|
||||||
|
startPolling(extractedText, translatedText, aiDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
|
||||||
|
}
|
||||||
|
const { description } = await res.json()
|
||||||
|
setAiDescription(description)
|
||||||
|
} catch (err) {
|
||||||
|
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
|
||||||
|
setTimeout(() => setDescError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setGeneratingDesc(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const callExtract = async (modeOverride: string) => {
|
||||||
|
setExtracting(true)
|
||||||
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemKey,
|
||||||
|
ocrMode: modeOverride,
|
||||||
|
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setExtractPending(true)
|
||||||
|
startPolling(extractedText, translatedText, aiDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
setExtractedText(result.extractedText || null)
|
||||||
|
setEditedExtractedText(result.extractedText || '')
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
} catch (err) {
|
||||||
|
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
||||||
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setExtracting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
|
||||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{itemKey && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
|
||||||
color: showTags ? '#fff' : 'var(--text-primary)',
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
|
||||||
}}
|
|
||||||
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
|
||||||
title="Tags"
|
|
||||||
>
|
|
||||||
🏷
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onAiTag && (
|
|
||||||
<button
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setAiTagging(true)
|
|
||||||
setAiTagError(null)
|
|
||||||
try {
|
|
||||||
await onAiTag()
|
|
||||||
onTagsChanged?.()
|
|
||||||
} catch (err) {
|
|
||||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
|
||||||
setTimeout(() => setAiTagError(null), 4000)
|
|
||||||
} finally {
|
|
||||||
setAiTagging(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={aiTagging}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
|
||||||
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
|
||||||
}}
|
|
||||||
aria-label="AI Tag this image"
|
|
||||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
|
||||||
>
|
|
||||||
{aiTagging ? (
|
|
||||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}>⟳</span>
|
|
||||||
) : '✨'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTags ? (
|
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-full">
|
<div className="relative flex-1 min-h-0 min-w-0">
|
||||||
{/* Image */}
|
|
||||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={name}
|
|
||||||
className="object-contain rounded-lg"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
{onPrev && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Previous"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onNext && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
|
||||||
aria-label="Next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Tag panel */}
|
|
||||||
<div
|
|
||||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="max-w-full max-h-full object-contain rounded-lg"
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Prev / Next */}
|
||||||
{onPrev && (
|
{onPrev && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
@@ -197,8 +251,372 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Text overlay */}
|
||||||
|
{showTextOverlay && displayText && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{extractedText && translatedText && (
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOriginal((v) => !v)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
>
|
||||||
|
{showOriginal ? 'Show Translation' : 'Show Original'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Floating controls ── */}
|
||||||
|
|
||||||
|
{/* Filename pill — bottom-left */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||||
|
>
|
||||||
|
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags + Close — top-right */}
|
||||||
|
<div
|
||||||
|
className="absolute top-4 right-4 flex items-center gap-1.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{itemKey && !showTags && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
|
aria-label="Show tags"
|
||||||
|
title="Tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!showTags && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
|
aria-label="Close"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text display button — bottom-right, hidden when panel open */}
|
||||||
|
{!showTags && extractedText && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||||||
|
className={`absolute bottom-4 right-4 ${smallBtn}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||||||
|
title="Display text"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="15" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
|
{showTags && (
|
||||||
|
<MediaTagPanel
|
||||||
|
itemKey={itemKey!}
|
||||||
|
onHide={() => setShowTags(false)}
|
||||||
|
onClose={onClose}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
onAiTag={readOnly ? undefined : onAiTag}
|
||||||
|
readOnly={readOnly}
|
||||||
|
>
|
||||||
|
{/* Description section */}
|
||||||
|
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Description
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateDescription}
|
||||||
|
disabled={generatingDesc || descPending}
|
||||||
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
||||||
|
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||||
|
>
|
||||||
|
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={editedDescription}
|
||||||
|
onChange={(e) => setEditedDescription(e.target.value)}
|
||||||
|
placeholder="No description yet…"
|
||||||
|
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
minHeight: '3.5rem',
|
||||||
|
maxHeight: '8rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editedDescription !== (aiDescription ?? '') && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingDesc(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/ai-tagging/fields', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
||||||
|
})
|
||||||
|
setAiDescription(editedDescription)
|
||||||
|
} finally {
|
||||||
|
setSavingDesc(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingDesc}
|
||||||
|
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{savingDesc ? '⟳ Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text extraction section — only for images */}
|
||||||
|
{isImage && (
|
||||||
|
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Text Extraction
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => callExtract('llm')}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
aria-label="Extract text with AI"
|
||||||
|
title="Extract with AI (skips OCR)"
|
||||||
|
>
|
||||||
|
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => callExtract('tesseract')}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!extracting && !extractPending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ocrLanguageInput}
|
||||||
|
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||||
|
placeholder={defaultOcrLanguages}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
width: 120,
|
||||||
|
}}
|
||||||
|
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
|
||||||
|
|
||||||
|
{extractedText && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Extracted Text
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={editedExtractedText}
|
||||||
|
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||||
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
minHeight: '4rem',
|
||||||
|
maxHeight: '10rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editedExtractedText !== extractedText && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingText(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/ai-tagging/fields', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||||||
|
})
|
||||||
|
setExtractedText(editedExtractedText)
|
||||||
|
} finally {
|
||||||
|
setSavingText(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingText}
|
||||||
|
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{savingText ? '⟳ Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{translatedText && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Translation
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
||||||
|
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{translatedText}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
|
placeholder="Source lang…"
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
width: 100,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setRetranslating(true)
|
||||||
|
setTranslatePending(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||||
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setTranslatePending(true)
|
||||||
|
startPolling(extractedText, translatedText, aiDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRetranslating(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={retranslating || translatePending}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!retranslating && !translatePending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!translatePending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MediaTagPanel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,23 +23,30 @@ 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)
|
||||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||||
|
const [doomScrollEntries, setDoomScrollEntries] = useState<FileEntry[]>([])
|
||||||
|
const [doomScrollEntriesLoading, setDoomScrollEntriesLoading] = useState(false)
|
||||||
|
const [doomScrollEntriesLoaded, setDoomScrollEntriesLoaded] = useState(false)
|
||||||
|
const [pendingOpen, setPendingOpen] = useState<string | null>(null)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -71,6 +80,17 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
loadPath(initialPath)
|
loadPath(initialPath)
|
||||||
}, [loadPath, initialPath])
|
}, [loadPath, initialPath])
|
||||||
|
|
||||||
|
// Invalidate doom scroll entry cache when the user navigates to a different directory
|
||||||
|
useEffect(() => {
|
||||||
|
setDoomScrollEntries([])
|
||||||
|
setDoomScrollEntriesLoaded(false)
|
||||||
|
setDoomScrollEntriesLoading(false)
|
||||||
|
setDoomScrollLoading(false)
|
||||||
|
}, [currentPath])
|
||||||
|
|
||||||
|
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
||||||
|
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||||
|
|
||||||
const fetchAssignments = useCallback(() => {
|
const fetchAssignments = useCallback(() => {
|
||||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -80,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(() => {
|
||||||
@@ -95,6 +125,21 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
.finally(() => setRecursiveLoading(false))
|
.finally(() => setRecursiveLoading(false))
|
||||||
}, [libraryId, recursiveLoaded, recursiveLoading])
|
}, [libraryId, recursiveLoaded, recursiveLoading])
|
||||||
|
|
||||||
|
const fetchDoomScrollEntries = useCallback(() => {
|
||||||
|
if (doomScrollEntriesLoaded || doomScrollEntriesLoading) return
|
||||||
|
setDoomScrollEntriesLoading(true)
|
||||||
|
fetch(
|
||||||
|
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(currentPath)}&recursive=true`
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: DirectoryListing) => {
|
||||||
|
setDoomScrollEntries(data.entries)
|
||||||
|
setDoomScrollEntriesLoaded(true)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setDoomScrollEntriesLoading(false))
|
||||||
|
}, [libraryId, currentPath, doomScrollEntriesLoaded, doomScrollEntriesLoading])
|
||||||
|
|
||||||
// Fetch the full recursive listing the first time any filter becomes active
|
// Fetch the full recursive listing the first time any filter becomes active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filtersActive) return
|
if (!filtersActive) return
|
||||||
@@ -182,27 +227,65 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
fetchRecursive()
|
fetchRecursive()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recursiveLoaded) {
|
// No filters: scope to current directory
|
||||||
|
if (doomScrollEntriesLoaded) {
|
||||||
setDoomScrollActive(true)
|
setDoomScrollActive(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDoomScrollLoading(true)
|
setDoomScrollLoading(true)
|
||||||
fetchRecursive()
|
fetchDoomScrollEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate doom scroll once the recursive listing finishes loading (when triggered by button)
|
// Activate doom scroll once the appropriate listing finishes loading (when triggered by button)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) {
|
if (!doomScrollLoading) return
|
||||||
|
const filtersDone = filtersActive && !recursiveLoading && recursiveLoaded
|
||||||
|
const noFiltersDone = !filtersActive && !doomScrollEntriesLoading && doomScrollEntriesLoaded
|
||||||
|
if (filtersDone || noFiltersDone) {
|
||||||
setDoomScrollLoading(false)
|
setDoomScrollLoading(false)
|
||||||
setDoomScrollActive(true)
|
setDoomScrollActive(true)
|
||||||
}
|
}
|
||||||
}, [doomScrollLoading, recursiveLoading, recursiveLoaded])
|
}, [
|
||||||
|
doomScrollLoading, filtersActive,
|
||||||
|
recursiveLoading, recursiveLoaded,
|
||||||
|
doomScrollEntriesLoading, doomScrollEntriesLoaded,
|
||||||
|
])
|
||||||
|
|
||||||
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
|
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
|
||||||
// When no filters, doom scroll uses the full recursiveEntries.
|
// When no filters, doom scroll uses files recursively under the current directory.
|
||||||
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries)
|
// In both cases entries come from recursive API calls so entry.name is the full relative path.
|
||||||
|
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : doomScrollEntries)
|
||||||
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
|
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
|
||||||
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))
|
.map((e) => ({
|
||||||
|
url: e.url!,
|
||||||
|
name: e.name,
|
||||||
|
mediaType: e.mediaType as 'video' | 'image',
|
||||||
|
itemKey: `${libraryId}:mixed_file:${encodeURIComponent(e.name)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleViewInLibrary = useCallback((item: DoomScrollItem) => {
|
||||||
|
if (!item.itemKey) return
|
||||||
|
const rel = decodeURIComponent(item.itemKey.split(':mixed_file:')[1])
|
||||||
|
const parts = rel.split('/')
|
||||||
|
parts.pop()
|
||||||
|
const dir = parts.join('/')
|
||||||
|
setDoomScrollActive(false)
|
||||||
|
setPendingOpen(rel)
|
||||||
|
loadPath(dir)
|
||||||
|
}, [loadPath])
|
||||||
|
|
||||||
|
// Auto-open a file after navigating to its directory (from "view in library")
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingOpen || !listing) return
|
||||||
|
const filename = pendingOpen.split('/').pop()!
|
||||||
|
const entry = listing.entries.find((e) => e.name === filename && e.type === 'file')
|
||||||
|
if (!entry) return
|
||||||
|
setPendingOpen(null)
|
||||||
|
const idx = mediaEntries.indexOf(entry)
|
||||||
|
openMediaEntry(entry, idx >= 0 ? idx : 0)
|
||||||
|
// openMediaEntry is defined inline and depends on stable state; listing/pendingOpen are the real triggers
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [listing, pendingOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -211,6 +294,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
items={doomScrollItems}
|
items={doomScrollItems}
|
||||||
videoContext="mixed"
|
videoContext="mixed"
|
||||||
onClose={() => setDoomScrollActive(false)}
|
onClose={() => setDoomScrollActive(false)}
|
||||||
|
onViewInLibrary={handleViewInLibrary}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -259,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
|
||||||
@@ -321,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', {
|
||||||
@@ -335,6 +429,83 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
fetchAssignments()
|
fetchAssignments()
|
||||||
setFilterRefreshKey((k) => k + 1)
|
setFilterRefreshKey((k) => k + 1)
|
||||||
}}
|
}}
|
||||||
|
onExtractText={async (e, ocrLanguages) => {
|
||||||
|
if (e.type === 'directory') {
|
||||||
|
// Bulk extract for directory
|
||||||
|
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
|
const res = await fetch('/api/ai-tagging/extract-text-bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ libraryId, path: dirRel }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Text extraction failed')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single image extract
|
||||||
|
const itemKey = itemKeyFor(e)
|
||||||
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Text extraction failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDescribe={async (e) => {
|
||||||
|
if (e.type === 'directory') {
|
||||||
|
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
|
const res = await fetch('/api/ai-tagging/describe-bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ libraryId, path: dirRel }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Description generation failed')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const itemKey = itemKeyFor(e)
|
||||||
|
const res = await fetch('/api/ai-tagging/describe', {
|
||||||
|
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 ?? 'Description generation failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTranslate={async (e) => {
|
||||||
|
if (e.type === 'directory') {
|
||||||
|
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
|
const res = await fetch('/api/ai-tagging/translate-bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ libraryId, path: dirRel }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const itemKey = itemKeyFor(e)
|
||||||
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDelete={(e) => {
|
onDelete={(e) => {
|
||||||
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
||||||
@@ -375,9 +546,25 @@ 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}
|
||||||
|
showTags={modalShowTags}
|
||||||
|
onShowTagsChange={setModalShowTags}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onAiTag={!readOnly && modal.itemKey ? async () => {
|
||||||
|
const res = await fetch('/api/ai-tagging', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey: modal.itemKey }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
|
||||||
|
}
|
||||||
|
fetchAssignments()
|
||||||
|
setFilterRefreshKey((k) => k + 1)
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modal?.type === 'image' && (
|
{modal?.type === 'image' && (
|
||||||
@@ -386,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' },
|
||||||
@@ -451,7 +641,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { 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> }) {
|
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'
|
||||||
@@ -466,6 +656,14 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
|
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
|
const [textExtracting, setTextExtracting] = useState(false)
|
||||||
|
const [textExtractError, setTextExtractError] = useState<string | null>(null)
|
||||||
|
const [describing, setDescribing] = useState(false)
|
||||||
|
const [describeError, setDescribeError] = useState<string | null>(null)
|
||||||
|
const [translating, setTranslating] = useState(false)
|
||||||
|
const [translateError, setTranslateError] = useState<string | null>(null)
|
||||||
|
const [showOcrPrompt, setShowOcrPrompt] = useState(false)
|
||||||
|
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
@@ -496,7 +694,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onOpen(entry)}
|
onClick={() => onOpen(entry)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }}
|
||||||
className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all cursor-pointer"
|
className="group relative flex flex-col rounded-xl border text-xs transition-all cursor-pointer"
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
@@ -507,6 +705,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Inner wrapper — clips visual content to rounded corners */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden rounded-xl pointer-events-none">
|
||||||
{/* Thumbnail image — hidden until loaded */}
|
{/* Thumbnail image — hidden until loaded */}
|
||||||
{entry.thumbnailUrl && (
|
{entry.thumbnailUrl && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -564,6 +764,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
▶
|
▶
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tag button — top-left, shown on hover */}
|
{/* Tag button — top-left, shown on hover */}
|
||||||
<button
|
<button
|
||||||
@@ -576,11 +777,11 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
🏷
|
🏷
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Kebab menu — top-right, shown on hover */}
|
{/* Kebab menu — bottom-right, shown on hover */}
|
||||||
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image')) && (
|
{(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 top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" 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) }}
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
@@ -589,7 +790,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
className="absolute right-0 bottom-full mb-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)' }}
|
||||||
>
|
>
|
||||||
{onAiTag && entry.mediaType === 'image' && (
|
{onAiTag && entry.mediaType === 'image' && (
|
||||||
@@ -612,6 +813,162 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
✨ AI Tag
|
✨ AI Tag
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
setDescribing(true)
|
||||||
|
setDescribeError(null)
|
||||||
|
onDescribe(entry)
|
||||||
|
.catch((err) => setDescribeError(err instanceof Error ? err.message : 'Description generation failed'))
|
||||||
|
.finally(() => setDescribing(false))
|
||||||
|
}}
|
||||||
|
disabled={describing}
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
📝 Describe
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDescribe && entry.type === 'directory' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
setDescribing(true)
|
||||||
|
setDescribeError(null)
|
||||||
|
onDescribe(entry)
|
||||||
|
.catch((err) => setDescribeError(err instanceof Error ? err.message : 'Description generation failed'))
|
||||||
|
.finally(() => setDescribing(false))
|
||||||
|
}}
|
||||||
|
disabled={describing}
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
📝 Describe Folder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onExtractText && entry.mediaType === 'image' && !showOcrPrompt && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (ocrMode && ocrMode !== 'llm') {
|
||||||
|
setOcrLanguageInput('')
|
||||||
|
setShowOcrPrompt(true)
|
||||||
|
} else {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setTextExtracting(true)
|
||||||
|
setTextExtractError(null)
|
||||||
|
onExtractText(entry)
|
||||||
|
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||||
|
.finally(() => setTextExtracting(false))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={textExtracting}
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
🔍 Extract Text
|
||||||
|
</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' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
setTextExtracting(true)
|
||||||
|
setTextExtractError(null)
|
||||||
|
onExtractText(entry)
|
||||||
|
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||||
|
.finally(() => setTextExtracting(false))
|
||||||
|
}}
|
||||||
|
disabled={textExtracting}
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
🔍 Extract Text for Folder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
setTranslating(true)
|
||||||
|
setTranslateError(null)
|
||||||
|
onTranslate(entry)
|
||||||
|
.catch((err) => setTranslateError(err instanceof Error ? err.message : 'Translation failed'))
|
||||||
|
.finally(() => setTranslating(false))
|
||||||
|
}}
|
||||||
|
disabled={translating}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onRename && (
|
{onRename && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -667,6 +1024,72 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Text extraction status overlay */}
|
||||||
|
{(textExtracting || textExtractError) && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
|
||||||
|
style={{ backgroundColor: textExtractError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span style={{ color: textExtractError ? '#fca5a5' : 'var(--text-secondary)' }}>
|
||||||
|
{textExtractError ?? 'Extracting text…'}
|
||||||
|
</span>
|
||||||
|
{textExtractError && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTextExtractError(null)}
|
||||||
|
className="ml-2 underline text-xs"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description generation status overlay */}
|
||||||
|
{(describing || describeError) && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
|
||||||
|
style={{ backgroundColor: describeError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span style={{ color: describeError ? '#fca5a5' : 'var(--text-secondary)' }}>
|
||||||
|
{describeError ?? 'Generating description…'}
|
||||||
|
</span>
|
||||||
|
{describeError && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDescribeError(null)}
|
||||||
|
className="ml-2 underline text-xs"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Translation status overlay */}
|
||||||
|
{(translating || translateError) && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
|
||||||
|
style={{ backgroundColor: translateError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span style={{ color: translateError ? '#fca5a5' : 'var(--text-secondary)' }}>
|
||||||
|
{translateError ?? 'Translating…'}
|
||||||
|
</span>
|
||||||
|
{translateError && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTranslateError(null)}
|
||||||
|
className="ml-2 underline text-xs"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation overlay */}
|
{/* Delete confirmation overlay */}
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
import { useUserSettings } from '@/hooks/useUserSettings'
|
import { useUserSettings } from '@/hooks/useUserSettings'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,18 +12,23 @@ interface Props {
|
|||||||
onNext?: () => void
|
onNext?: () => void
|
||||||
itemKey?: string
|
itemKey?: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => 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, context = 'mixed' }: Props) {
|
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
|
||||||
const settings = useUserSettings()
|
const settings = useUserSettings()
|
||||||
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
||||||
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
||||||
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
|
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
|
||||||
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTags, setShowTags] = useState(
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
)
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -43,56 +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>
|
|
||||||
)}
|
|
||||||
<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}
|
||||||
@@ -100,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} />
|
|
||||||
</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"
|
||||||
>
|
>
|
||||||
@@ -163,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"
|
||||||
>
|
>
|
||||||
@@ -171,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ import TagBadge from './TagBadge'
|
|||||||
interface Props {
|
interface Props {
|
||||||
itemKey: string
|
itemKey: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
|
refreshKey?: number
|
||||||
|
hideDescription?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AllTags {
|
interface AllTags {
|
||||||
@@ -14,7 +17,7 @@ interface AllTags {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagSelector({ itemKey, onTagsChanged }: 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: [],
|
||||||
@@ -23,6 +26,11 @@ export default function TagSelector({ itemKey, onTagsChanged }: Props) {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// AI description state
|
||||||
|
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||||||
|
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||||||
|
const [descError, setDescError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Per-category search text
|
// Per-category search text
|
||||||
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({})
|
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
@@ -53,10 +61,25 @@ export default function TagSelector({ itemKey, onTagsChanged }: Props) {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const fetchAiFields = useCallback(() => {
|
||||||
|
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { aiDescription: string | null }) => {
|
||||||
|
setAiDescription(data.aiDescription)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [itemKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
Promise.all([fetchAssigned(), fetchAll()]).finally(() => setLoading(false))
|
Promise.all([fetchAssigned(), fetchAll(), fetchAiFields()]).finally(() => setLoading(false))
|
||||||
}, [fetchAssigned, fetchAll])
|
}, [fetchAssigned, fetchAll, fetchAiFields])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshKey !== undefined && refreshKey > 0) {
|
||||||
|
fetchAssigned()
|
||||||
|
}
|
||||||
|
}, [refreshKey, fetchAssigned])
|
||||||
|
|
||||||
const isAssigned = (tagId: string) => assigned.tags.some((t) => t.id === tagId)
|
const isAssigned = (tagId: string) => assigned.tags.some((t) => t.id === tagId)
|
||||||
|
|
||||||
@@ -158,8 +181,70 @@ export default function TagSelector({ itemKey, onTagsChanged }: Props) {
|
|||||||
|
|
||||||
const assignedCategoryMap = Object.fromEntries(assigned.categories.map((c) => [c.id, c]))
|
const assignedCategoryMap = Object.fromEntries(assigned.categories.map((c) => [c.id, c]))
|
||||||
|
|
||||||
|
const handleGenerateDescription = async () => {
|
||||||
|
setGeneratingDesc(true)
|
||||||
|
setDescError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/describe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
|
||||||
|
}
|
||||||
|
if (res.status === 202) {
|
||||||
|
setDescError('Queued — check AI Integrations for progress')
|
||||||
|
setTimeout(() => setDescError(null), 4000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* AI description */}
|
||||||
|
{!hideDescription && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{aiDescription && (
|
||||||
|
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{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>
|
||||||
|
</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">
|
||||||
@@ -193,23 +278,25 @@ export default function TagSelector({ itemKey, onTagsChanged }: Props) {
|
|||||||
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)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -218,13 +305,17 @@ export default function TagSelector({ itemKey, onTagsChanged }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tag picker grouped by category */}
|
{/* Tag picker grouped by category */}
|
||||||
<div className="flex flex-col gap-2">
|
{!readOnly && <div className="flex flex-col gap-2">
|
||||||
{all.categories.map((category) => {
|
{all.categories.map((category) => {
|
||||||
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
||||||
const search = categorySearches[category.id] ?? ''
|
const search = categorySearches[category.id] ?? ''
|
||||||
const visibleTags = categoryTags
|
const filtered = categoryTags.filter(
|
||||||
.filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase()))
|
(t) => !search || t.name.toLowerCase().includes(search.toLowerCase())
|
||||||
.slice(0, 25)
|
)
|
||||||
|
const visibleTags = [
|
||||||
|
...filtered.filter((t) => isAssigned(t.id)),
|
||||||
|
...filtered.filter((t) => !isAssigned(t.id)),
|
||||||
|
].slice(0, 25)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category.id}>
|
<div key={category.id}>
|
||||||
@@ -443,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged }: Props) {
|
|||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ export async function register() {
|
|||||||
|
|
||||||
const { startScheduler } = await import('./lib/scheduler')
|
const { startScheduler } = await import('./lib/scheduler')
|
||||||
startScheduler()
|
startScheduler()
|
||||||
|
|
||||||
|
const { initJobProcessor } = await import('./lib/ai-jobs')
|
||||||
|
initJobProcessor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
355
src/lib/ai-jobs.ts
Normal file
355
src/lib/ai-jobs.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { getAiMaxRetries } from './app-settings'
|
||||||
|
import { tagSingleItem, generateItemDescription, extractItemText, translateItemText } from './ai-tagger'
|
||||||
|
|
||||||
|
export type AiJobType = 'tag' | 'describe' | 'extract' | 'translate'
|
||||||
|
export type AiJobStatus = 'queued' | 'running' | 'completed' | 'failed'
|
||||||
|
|
||||||
|
export interface AiJob {
|
||||||
|
id: string
|
||||||
|
itemKey: string
|
||||||
|
libraryId: string
|
||||||
|
jobType: AiJobType
|
||||||
|
status: AiJobStatus
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
maxRetries: number
|
||||||
|
createdAt: number
|
||||||
|
startedAt: number | null
|
||||||
|
completedAt: number | null
|
||||||
|
itemTitle: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiJobRow {
|
||||||
|
id: string
|
||||||
|
item_key: string
|
||||||
|
library_id: string
|
||||||
|
job_type: string
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
max_retries: number
|
||||||
|
created_at: number
|
||||||
|
started_at: number | null
|
||||||
|
completed_at: number | null
|
||||||
|
item_title: string | null
|
||||||
|
payload: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToJob(row: AiJobRow): AiJob {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
itemKey: row.item_key,
|
||||||
|
libraryId: row.library_id,
|
||||||
|
jobType: row.job_type as AiJobType,
|
||||||
|
status: row.status as AiJobStatus,
|
||||||
|
error: row.error,
|
||||||
|
attempt: row.attempt,
|
||||||
|
maxRetries: row.max_retries,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
completedAt: row.completed_at,
|
||||||
|
itemTitle: row.item_title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the title of a media item for display purposes.
|
||||||
|
*/
|
||||||
|
function resolveItemTitle(itemKey: string): string | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT title FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as { title: string | null } | undefined
|
||||||
|
return row?.title ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Enqueue ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue an AI job. Deduplicates: if a queued/running job with the same
|
||||||
|
* item_key + job_type already exists, returns its ID instead.
|
||||||
|
*/
|
||||||
|
export function enqueueJob(
|
||||||
|
itemKey: string,
|
||||||
|
jobType: AiJobType,
|
||||||
|
libraryId: string,
|
||||||
|
sourceLanguage?: string,
|
||||||
|
payload?: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// Deduplication: check for existing queued/running job
|
||||||
|
const existing = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM ai_jobs
|
||||||
|
WHERE item_key = ? AND job_type = ? AND status IN ('queued', 'running')`
|
||||||
|
)
|
||||||
|
.get(itemKey, jobType) as { id: string } | undefined
|
||||||
|
if (existing) return existing.id
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const maxRetries = getAiMaxRetries()
|
||||||
|
const title = resolveItemTitle(itemKey)
|
||||||
|
|
||||||
|
// Store sourceLanguage in the error field temporarily for translate jobs
|
||||||
|
// (it's null at creation, so we repurpose it briefly — cleared when the job runs)
|
||||||
|
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title, payload)
|
||||||
|
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
|
||||||
|
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
|
||||||
|
|
||||||
|
// Wake the processor
|
||||||
|
wakeProcessor()
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue jobs for all media items in a directory (for bulk operations).
|
||||||
|
* Returns the list of job IDs created.
|
||||||
|
*/
|
||||||
|
export function enqueueBulkJobs(
|
||||||
|
libraryId: string,
|
||||||
|
dirPath: string,
|
||||||
|
jobType: AiJobType,
|
||||||
|
itemTypeFilter?: string,
|
||||||
|
extFilter?: Set<string>,
|
||||||
|
): string[] {
|
||||||
|
const db = getDb()
|
||||||
|
const prefix = dirPath
|
||||||
|
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
|
||||||
|
: `${libraryId}:mixed_file:`
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.prepare('SELECT item_key, item_type, file_path FROM media_items WHERE item_key LIKE ? AND item_type = ?')
|
||||||
|
.all(`${prefix}%`, itemTypeFilter ?? 'mixed_file') as Array<{ item_key: string; item_type: string; file_path: string | null }>
|
||||||
|
|
||||||
|
const path = require('path')
|
||||||
|
const jobIds: string[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.file_path) continue
|
||||||
|
if (extFilter) {
|
||||||
|
const ext = path.extname(item.file_path).toLowerCase()
|
||||||
|
if (!extFilter.has(ext)) continue
|
||||||
|
}
|
||||||
|
const jobId = enqueueJob(item.item_key, jobType, libraryId)
|
||||||
|
jobIds.push(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobIds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Query ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getJobQueue(): AiJob[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status IN ('running', 'queued')
|
||||||
|
ORDER BY
|
||||||
|
CASE status WHEN 'running' THEN 0 ELSE 1 END,
|
||||||
|
created_at ASC`
|
||||||
|
)
|
||||||
|
.all() as AiJobRow[]
|
||||||
|
return rows.map(rowToJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJobHistory(limit = 50): AiJob[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status IN ('completed', 'failed')
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(limit) as AiJobRow[]
|
||||||
|
return rows.map(rowToJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(jobId: string): AiJob | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM ai_jobs WHERE id = ?')
|
||||||
|
.get(jobId) as AiJobRow | undefined
|
||||||
|
return row ? rowToJob(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function retryJob(jobId: string): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ai_jobs SET status = 'queued', error = NULL, attempt = 0, started_at = NULL, completed_at = NULL
|
||||||
|
WHERE id = ? AND status = 'failed'`
|
||||||
|
)
|
||||||
|
.run(jobId)
|
||||||
|
if (result.changes > 0) {
|
||||||
|
wakeProcessor()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelJob(jobId: string): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE id = ? AND status = 'queued'")
|
||||||
|
.run(jobId)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAllQueued(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE status = 'queued'")
|
||||||
|
.run()
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearJobHistory(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE status IN ('completed', 'failed')")
|
||||||
|
.run()
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Processor ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let processorRunning = false
|
||||||
|
let processorWake: (() => void) | null = null
|
||||||
|
|
||||||
|
function wakeProcessor(): void {
|
||||||
|
if (processorWake) {
|
||||||
|
processorWake()
|
||||||
|
} else if (!processorRunning) {
|
||||||
|
runProcessor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processNextJob(): Promise<boolean> {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status = 'queued'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get() as AiJobRow | undefined
|
||||||
|
|
||||||
|
if (!row) return false
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
||||||
|
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
||||||
|
// Parse job payload (carries per-call overrides, e.g. ocrLanguages for extract jobs)
|
||||||
|
const jobPayload = row.payload ? (JSON.parse(row.payload) as Record<string, string>) : null
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
||||||
|
).run(now, row.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (row.job_type) {
|
||||||
|
case 'tag':
|
||||||
|
await tagSingleItem(row.item_key)
|
||||||
|
break
|
||||||
|
case 'describe':
|
||||||
|
await generateItemDescription(row.item_key)
|
||||||
|
break
|
||||||
|
case 'extract':
|
||||||
|
await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
|
||||||
|
break
|
||||||
|
case 'translate':
|
||||||
|
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'completed', completed_at = ? WHERE id = ?"
|
||||||
|
).run(Date.now(), row.id)
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||||
|
const attempt = row.attempt + 1
|
||||||
|
|
||||||
|
if (attempt < row.max_retries) {
|
||||||
|
// Re-queue for retry
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'queued', attempt = ?, error = ?, started_at = NULL WHERE id = ?"
|
||||||
|
).run(attempt, errorMessage, row.id)
|
||||||
|
} else {
|
||||||
|
// Final failure
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'failed', attempt = ?, error = ?, completed_at = ? WHERE id = ?"
|
||||||
|
).run(attempt, errorMessage, Date.now(), row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[ai-jobs] Job ${row.id} (${row.job_type} for "${row.item_key}") failed (attempt ${attempt}/${row.max_retries}):`,
|
||||||
|
errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProcessor(): Promise<void> {
|
||||||
|
if (processorRunning) return
|
||||||
|
processorRunning = true
|
||||||
|
console.log('[ai-jobs] Processor started')
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const hadWork = await processNextJob()
|
||||||
|
if (!hadWork) {
|
||||||
|
// Wait for a wake signal or timeout after 60s (then check again for safety)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
processorWake = resolve
|
||||||
|
setTimeout(() => {
|
||||||
|
processorWake = null
|
||||||
|
resolve()
|
||||||
|
}, 60_000)
|
||||||
|
})
|
||||||
|
processorWake = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ai-jobs] Processor crashed:', err)
|
||||||
|
} finally {
|
||||||
|
processorRunning = false
|
||||||
|
console.log('[ai-jobs] Processor stopped')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the job processor. Called on server startup.
|
||||||
|
* Resets any jobs stuck in 'running' state (from a previous crash) back to 'queued'.
|
||||||
|
*/
|
||||||
|
export function initJobProcessor(): void {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("UPDATE ai_jobs SET status = 'queued', started_at = NULL WHERE status = 'running'")
|
||||||
|
.run()
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`[ai-jobs] Reset ${result.changes} stuck running job(s) to queued`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any queued jobs and start the processor
|
||||||
|
const pending = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM ai_jobs WHERE status = 'queued'")
|
||||||
|
.get() as { count: number }
|
||||||
|
if (pending.count > 0) {
|
||||||
|
runProcessor()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Library, Tag, TagCategory } from '@/types'
|
import type { Library, Tag, TagCategory } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getAiConfig } from './app-settings'
|
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
|
||||||
import { getTags, getCategories, addTagToItem } from './tags'
|
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
|
||||||
import { getThumbnailPath } 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'
|
||||||
|
|
||||||
@@ -13,6 +13,14 @@ const REQUEST_TIMEOUT_MS = 30_000
|
|||||||
const MAX_CONSECUTIVE_FAILURES = 3
|
const MAX_CONSECUTIVE_FAILURES = 3
|
||||||
|
|
||||||
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'])
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg'])
|
||||||
|
|
||||||
|
const VIDEO_FRAME_PERCENTAGES = [0.10, 0.25, 0.50, 0.75, 0.90]
|
||||||
|
|
||||||
|
interface ResolvedMedia {
|
||||||
|
path: string
|
||||||
|
mediaType: 'image' | 'video'
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaItemRow {
|
interface MediaItemRow {
|
||||||
item_key: string
|
item_key: string
|
||||||
@@ -22,10 +30,10 @@ interface MediaItemRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the absolute path to the best image for a media item.
|
* Resolve the absolute path to the best image (or video) for a media item.
|
||||||
* Returns null if no suitable image is found.
|
* Returns null if no suitable media is found.
|
||||||
*/
|
*/
|
||||||
function resolveItemImage(libraryRoot: string, item: MediaItemRow): string | null {
|
function resolveItemImage(libraryRoot: string, item: MediaItemRow): ResolvedMedia | null {
|
||||||
switch (item.item_type) {
|
switch (item.item_type) {
|
||||||
case 'movie':
|
case 'movie':
|
||||||
case 'tv_series': {
|
case 'tv_series': {
|
||||||
@@ -40,7 +48,7 @@ function resolveItemImage(libraryRoot: string, item: MediaItemRow): string | nul
|
|||||||
)
|
)
|
||||||
if (!relPath) return null
|
if (!relPath) return null
|
||||||
const absPath = path.join(libraryRoot, relPath)
|
const absPath = path.join(libraryRoot, relPath)
|
||||||
if (fs.existsSync(absPath)) return absPath
|
if (fs.existsSync(absPath)) return { path: absPath, mediaType: 'image' }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -58,7 +66,7 @@ function resolveItemImage(libraryRoot: string, item: MediaItemRow): string | nul
|
|||||||
)
|
)
|
||||||
if (!relPath) return null
|
if (!relPath) return null
|
||||||
const absPath = path.join(libraryRoot, relPath)
|
const absPath = path.join(libraryRoot, relPath)
|
||||||
if (fs.existsSync(absPath)) return absPath
|
if (fs.existsSync(absPath)) return { path: absPath, mediaType: 'image' }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -70,16 +78,16 @@ function resolveItemImage(libraryRoot: string, item: MediaItemRow): string | nul
|
|||||||
if (!item.file_path) return null
|
if (!item.file_path) return null
|
||||||
const seasonDir = path.join(libraryRoot, item.file_path)
|
const seasonDir = path.join(libraryRoot, item.file_path)
|
||||||
const posterFile = findFile(seasonDir, /^(poster|cover|folder)$/i)
|
const posterFile = findFile(seasonDir, /^(poster|cover|folder)$/i)
|
||||||
if (posterFile) return path.join(seasonDir, posterFile)
|
if (posterFile) return { path: path.join(seasonDir, posterFile), mediaType: 'image' }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'mixed_file': {
|
case 'mixed_file': {
|
||||||
// For mixed files, tag only actual images (not videos or other files)
|
|
||||||
if (!item.file_path) return null
|
if (!item.file_path) return null
|
||||||
const ext = path.extname(item.file_path).toLowerCase()
|
const ext = path.extname(item.file_path).toLowerCase()
|
||||||
if (!IMAGE_EXTENSIONS.has(ext)) return null
|
if (IMAGE_EXTENSIONS.has(ext)) return { path: path.join(libraryRoot, item.file_path), mediaType: 'image' }
|
||||||
return path.join(libraryRoot, item.file_path)
|
if (VIDEO_EXTENSIONS.has(ext)) return { path: path.join(libraryRoot, item.file_path), mediaType: 'video' }
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -89,8 +97,19 @@ function resolveItemImage(libraryRoot: string, item: MediaItemRow): string | nul
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the system prompt that instructs the LLM to select matching tags.
|
* Build the system prompt that instructs the LLM to select matching tags.
|
||||||
|
* If currentTags are provided they are included as context to help the model
|
||||||
|
* understand the content before selecting additional tags.
|
||||||
*/
|
*/
|
||||||
function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string {
|
interface TagPromptContext {
|
||||||
|
currentTags?: Tag[]
|
||||||
|
mediaContext?: 'image' | 'video'
|
||||||
|
aiDescription?: string | null
|
||||||
|
extractedText?: string | null
|
||||||
|
customInstruction?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): string {
|
||||||
|
const { currentTags, mediaContext = 'image', aiDescription, extractedText, customInstruction } = ctx
|
||||||
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
|
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||||
|
|
||||||
const grouped: Record<string, { id: string; name: string }[]> = {}
|
const grouped: Record<string, { id: string; name: string }[]> = {}
|
||||||
@@ -105,24 +124,55 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string {
|
|||||||
lines.push(`[${catName}] ${tagList}`)
|
lines.push(`[${catName}] ${tagList}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const isVideo = mediaContext === 'video'
|
||||||
'You are an image tagger. Given the image, select which of the following tags apply.',
|
const contentWord = isVideo ? 'video frames' : 'image'
|
||||||
'Return ONLY a JSON array of tag IDs that match the image. Do not invent new tags.',
|
|
||||||
'If no tags match, return an empty array: []',
|
const parts: string[] = [
|
||||||
'',
|
`You are a media tagger. Given the ${contentWord}, select which of the following tags apply.`,
|
||||||
'Available tags:',
|
'Return ONLY a JSON array of tag IDs that match (e.g., ["tag-apple", "tag-orange"]). Do not invent new tags. Do not return any text other than what is inside the JSON array.',
|
||||||
...lines,
|
'If no tags match, return an empty array (e.i., [])',
|
||||||
].join('\n')
|
]
|
||||||
|
|
||||||
|
if (customInstruction) {
|
||||||
|
parts.push('')
|
||||||
|
parts.push(customInstruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiDescription) {
|
||||||
|
parts.push('')
|
||||||
|
parts.push(`AI-generated description of this content: ${aiDescription}`)
|
||||||
|
parts.push('Use this description as additional context when selecting tags.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedText) {
|
||||||
|
parts.push('')
|
||||||
|
parts.push(`Text extracted from the image: ${extractedText}`)
|
||||||
|
parts.push('Use this text as additional context when selecting tags. If the text contains dialogue, it may provide important clues about the content.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTags && currentTags.length > 0) {
|
||||||
|
const currentTagNames = currentTags.map((t) => t.name).join(', ')
|
||||||
|
parts.push('')
|
||||||
|
parts.push(`This content already has the following tags applied: ${currentTagNames}`)
|
||||||
|
parts.push('Use these as context to better understand the content when selecting tags.')
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('')
|
||||||
|
parts.push('Available tags:')
|
||||||
|
parts.push(...lines)
|
||||||
|
|
||||||
|
return parts.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call the OpenAI-compatible vision API to get tag suggestions for an image.
|
* Call the OpenAI-compatible vision API to get tag suggestions for one or more images.
|
||||||
*/
|
*/
|
||||||
async function callVisionApi(
|
async function callVisionApi(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
model: string,
|
model: string,
|
||||||
base64Image: string,
|
base64Images: string[],
|
||||||
systemPrompt: string
|
systemPrompt: string,
|
||||||
|
maxTokens: number,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
||||||
|
|
||||||
@@ -140,15 +190,13 @@ async function callVisionApi(
|
|||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: base64Images.map((b64) => ({
|
||||||
{
|
type: 'image_url',
|
||||||
type: 'image_url',
|
image_url: { url: `data:image/jpeg;base64,${b64}` },
|
||||||
image_url: { url: `data:image/jpeg;base64,${base64Image}` },
|
})),
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: 512,
|
max_tokens: maxTokens,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -178,19 +226,19 @@ async function callVisionApi(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Run AI tagging for a single library. Called after the scanner finishes.
|
* Run AI tagging for a single library. Called after the scanner finishes.
|
||||||
* Processes up to BATCH_LIMIT untagged items per invocation.
|
* Enqueues up to BATCH_LIMIT untagged items as jobs for the processor.
|
||||||
*/
|
*/
|
||||||
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
||||||
const config = getAiConfig()
|
const config = getEffectiveAiConfig(library.id)
|
||||||
if (!config.enabled || !config.endpoint || !config.model) return
|
const taggingModel = config.modelTagging || config.model
|
||||||
|
if (!config.enabled || !config.endpoint || !taggingModel) return
|
||||||
|
|
||||||
const tags = getTags()
|
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
|
||||||
const categories = getCategories()
|
const allTags = getTags()
|
||||||
|
|
||||||
|
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
|
||||||
if (tags.length === 0) return
|
if (tags.length === 0) return
|
||||||
|
|
||||||
const validTagIds = new Set(tags.map((t) => t.id))
|
|
||||||
const systemPrompt = buildTagPrompt(tags, categories)
|
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const untaggedItems = db
|
const untaggedItems = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -203,52 +251,28 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
|
|
||||||
if (untaggedItems.length === 0) return
|
if (untaggedItems.length === 0) return
|
||||||
|
|
||||||
console.log(`[ai-tagger] Processing ${untaggedItems.length} items in library "${library.name}"`)
|
// Import enqueueJob lazily to avoid circular dependency
|
||||||
|
const { enqueueJob } = await import('./ai-jobs')
|
||||||
|
|
||||||
let tagged = 0
|
let enqueued = 0
|
||||||
let consecutiveFailures = 0
|
|
||||||
const markTagged = db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?')
|
const markTagged = db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?')
|
||||||
|
|
||||||
for (const item of untaggedItems) {
|
for (const item of untaggedItems) {
|
||||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
const resolvedMedia = resolveItemImage(libraryRoot, item)
|
||||||
console.warn(`[ai-tagger] Aborting after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`)
|
if (!resolvedMedia) {
|
||||||
break
|
// No image or video available — mark as tagged so we don't retry every scan
|
||||||
}
|
|
||||||
|
|
||||||
const imagePath = resolveItemImage(libraryRoot, item)
|
|
||||||
if (!imagePath) {
|
|
||||||
// No image available — mark as tagged so we don't retry every scan
|
|
||||||
markTagged.run(Date.now(), item.item_key)
|
markTagged.run(Date.now(), item.item_key)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
enqueueJob(item.item_key, 'tag', library.id)
|
||||||
// Use the thumbnail cache for a smaller image
|
// Mark as tagged immediately so subsequent scans don't re-enqueue
|
||||||
const thumbnailPath = await getThumbnailPath(imagePath, library.id, 'image')
|
markTagged.run(Date.now(), item.item_key)
|
||||||
const base64 = fs.readFileSync(thumbnailPath, 'base64')
|
enqueued++
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
|
|
||||||
|
|
||||||
// Filter to valid tags only
|
|
||||||
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
|
||||||
for (const tagId of validIds) {
|
|
||||||
addTagToItem(item.item_key, tagId)
|
|
||||||
}
|
|
||||||
|
|
||||||
markTagged.run(Date.now(), item.item_key)
|
|
||||||
tagged++
|
|
||||||
consecutiveFailures = 0
|
|
||||||
} catch (err) {
|
|
||||||
consecutiveFailures++
|
|
||||||
console.warn(
|
|
||||||
`[ai-tagger] Failed to tag "${item.item_key}":`,
|
|
||||||
err instanceof Error ? err.message : err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagged > 0) {
|
if (enqueued > 0) {
|
||||||
console.log(`[ai-tagger] Tagged ${tagged}/${untaggedItems.length} items in library "${library.name}"`)
|
console.log(`[ai-tagger] Enqueued ${enqueued} tagging jobs for library "${library.name}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,19 +282,24 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
* Throws descriptive errors so the API route can return appropriate status codes.
|
* Throws descriptive errors so the API route can return appropriate status codes.
|
||||||
*/
|
*/
|
||||||
export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
||||||
const config = getAiConfig()
|
const libraryId = itemKey.split(':')[0]
|
||||||
if (!config.endpoint || !config.model) {
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
const taggingModel = config.modelTagging || config.model
|
||||||
|
if (!config.endpoint || !taggingModel) {
|
||||||
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = getTags()
|
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
|
||||||
const categories = getCategories()
|
const allTags = getTags()
|
||||||
|
const allCategories = getCategories()
|
||||||
|
|
||||||
|
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
|
||||||
|
const categories = allCategories.filter((c) => activeCategoryIds.has(c.id))
|
||||||
if (tags.length === 0) {
|
if (tags.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTagIds = new Set(tags.map((t) => t.id))
|
const validTagIds = new Set(tags.map((t) => t.id))
|
||||||
const systemPrompt = buildTagPrompt(tags, categories)
|
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
@@ -280,8 +309,6 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
|
||||||
const library = getLibrary(libraryId)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
||||||
@@ -293,10 +320,26 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
throw Object.assign(new Error('No image available for this item'), { code: 'NO_IMAGE' })
|
throw Object.assign(new Error('No image available for this item'), { code: 'NO_IMAGE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image')
|
let base64Images: string[]
|
||||||
const base64 = fs.readFileSync(thumbnailPath, 'base64')
|
if (imagePath.mediaType === 'video') {
|
||||||
|
const framePaths = await getVideoFramePaths(imagePath.path, libraryId, VIDEO_FRAME_PERCENTAGES)
|
||||||
|
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
||||||
|
} else {
|
||||||
|
const thumbnailPath = await getAiImagePath(imagePath.path, libraryId)
|
||||||
|
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
|
}
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
|
const { tags: currentItemTags } = getResolvedTagsForItem(itemKey)
|
||||||
|
const aiFields = getAiFields(itemKey)
|
||||||
|
const systemPromptWithContext = buildTagPrompt(tags, categories, {
|
||||||
|
currentTags: currentItemTags,
|
||||||
|
mediaContext: imagePath.mediaType,
|
||||||
|
aiDescription: aiFields.aiDescription,
|
||||||
|
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
||||||
|
customInstruction: config.promptTagger || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag)
|
||||||
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
||||||
|
|
||||||
for (const tagId of validIds) {
|
for (const tagId of validIds) {
|
||||||
@@ -307,3 +350,463 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
|
|
||||||
return validIds
|
return validIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Vision / Chat text helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call the vision API and return raw text content (no JSON parsing).
|
||||||
|
*/
|
||||||
|
async function callVisionApiText(
|
||||||
|
endpoint: string,
|
||||||
|
model: string,
|
||||||
|
base64Images: string[],
|
||||||
|
systemPrompt: string,
|
||||||
|
maxTokens: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: base64Images.map((b64) => ({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: `data:image/jpeg;base64,${b64}` },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: 0.1,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(`LLM API returned ${res.status}: ${text.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.choices?.[0]?.message?.content?.trim() ?? ''
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call the chat completions API with text-only input (no images).
|
||||||
|
*/
|
||||||
|
async function callChatApiText(
|
||||||
|
endpoint: string,
|
||||||
|
model: string,
|
||||||
|
systemPrompt: string,
|
||||||
|
userMessage: string,
|
||||||
|
maxTokens: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userMessage },
|
||||||
|
],
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: 0.1,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(`LLM API returned ${res.status}: ${text.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.choices?.[0]?.message?.content?.trim() ?? ''
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI description ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an AI description for a media item using a vision model.
|
||||||
|
* Stores the result in the ai_description column and returns it.
|
||||||
|
*/
|
||||||
|
export async function generateItemDescription(itemKey: string): Promise<string> {
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
const describeModel = config.modelDescribe || config.model
|
||||||
|
if (!config.endpoint || !describeModel) {
|
||||||
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const item = db
|
||||||
|
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as MediaItemRow | undefined
|
||||||
|
if (!item) {
|
||||||
|
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
const resolvedMedia = resolveItemImage(libraryRoot, item)
|
||||||
|
if (!resolvedMedia) {
|
||||||
|
throw Object.assign(new Error('No image available for this item'), { code: 'NO_IMAGE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let base64Images: string[]
|
||||||
|
if (resolvedMedia.mediaType === 'video') {
|
||||||
|
const framePaths = await getVideoFramePaths(resolvedMedia.path, libraryId, VIDEO_FRAME_PERCENTAGES)
|
||||||
|
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
||||||
|
} else {
|
||||||
|
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
||||||
|
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tags: currentTags } = getResolvedTagsForItem(itemKey)
|
||||||
|
const tagContext = currentTags.length > 0
|
||||||
|
? ` This content has the following tags applied describing it: ${currentTags.map((t) => t.name).join(', ')}. Use these as additional context and treat them as a source of truth, overriding any conflicting assumptions made from the image.`
|
||||||
|
: ''
|
||||||
|
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
|
||||||
|
|
||||||
|
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt, config.maxTokensDescribe)
|
||||||
|
|
||||||
|
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Text extraction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* Translation is not performed automatically — call translateItemText() separately.
|
||||||
|
* Returns { extractedText, translatedText } where translatedText is always null.
|
||||||
|
*/
|
||||||
|
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const item = db
|
||||||
|
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as MediaItemRow | undefined
|
||||||
|
if (!item) {
|
||||||
|
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
if (item.item_type !== 'mixed_file') {
|
||||||
|
throw Object.assign(new Error('Text extraction is only available for mixed library items'), { code: 'INVALID_TYPE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
if (library.type !== 'mixed') {
|
||||||
|
throw Object.assign(new Error('Text extraction is only available for mixed libraries'), { code: 'INVALID_TYPE' })
|
||||||
|
}
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
const resolvedMedia = resolveItemImage(libraryRoot, item)
|
||||||
|
if (!resolvedMedia || resolvedMedia.mediaType !== '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 base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
|
|
||||||
|
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
|
||||||
|
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
|
||||||
|
|
||||||
|
console.log(`[ocr] llm used for ${itemKey} (mode=${ocrMode})`)
|
||||||
|
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
|
||||||
|
|
||||||
|
if (!extractedText || extractedText === '[NO 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(extractedText, itemKey)
|
||||||
|
|
||||||
|
return { extractedText, translatedText: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate the extracted_text of an item into the preferred language.
|
||||||
|
* Returns the translated text or null if no text to translate.
|
||||||
|
*/
|
||||||
|
export async function translateItemText(itemKey: string, sourceLanguage?: string): Promise<string | null> {
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
const translateModel = config.modelTranslate || config.model
|
||||||
|
if (!config.endpoint || !translateModel) {
|
||||||
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT extracted_text FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as { extracted_text: string | null } | undefined
|
||||||
|
if (!row) {
|
||||||
|
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
if (!row.extracted_text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredLanguage = getPreferredLanguage()
|
||||||
|
if (!preferredLanguage) return null
|
||||||
|
|
||||||
|
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, sourceLanguage)
|
||||||
|
if (translatedText) {
|
||||||
|
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedText
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the extracted_text of an item.
|
||||||
|
*/
|
||||||
|
export function updateExtractedText(itemKey: string, text: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the ai_description of an item.
|
||||||
|
*/
|
||||||
|
export function updateAiDescription(itemKey: string, description: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate text to a target language using the chat API.
|
||||||
|
* Returns null if the text is already in the target language.
|
||||||
|
*/
|
||||||
|
async function translateText(
|
||||||
|
endpoint: string,
|
||||||
|
model: string,
|
||||||
|
text: string,
|
||||||
|
targetLanguage: string,
|
||||||
|
customInstruction = '',
|
||||||
|
maxTokens = 8192,
|
||||||
|
sourceLanguage?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
let systemPrompt: string
|
||||||
|
if (sourceLanguage) {
|
||||||
|
systemPrompt = `You are a translator. Translate the following text from ${sourceLanguage} to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
|
||||||
|
} else {
|
||||||
|
systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callChatApiText(endpoint, model, systemPrompt, text, maxTokens)
|
||||||
|
|
||||||
|
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text from all images in a directory within a mixed library.
|
||||||
|
* Returns the number of items processed.
|
||||||
|
*/
|
||||||
|
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
|
||||||
|
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 library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
if (library.type !== 'mixed') {
|
||||||
|
throw Object.assign(new Error('Text extraction is only available for mixed libraries'), { code: 'INVALID_TYPE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const prefix = dirPath
|
||||||
|
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
|
||||||
|
: `${libraryId}:mixed_file:`
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key LIKE ? AND item_type = ?')
|
||||||
|
.all(`${prefix}%`, 'mixed_file') as MediaItemRow[]
|
||||||
|
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
let processed = 0
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// Only process images
|
||||||
|
if (!item.file_path) continue
|
||||||
|
const ext = path.extname(item.file_path).toLowerCase()
|
||||||
|
if (!IMAGE_EXTENSIONS.has(ext)) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
await extractItemText(item.item_key)
|
||||||
|
processed++
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[ai-tagger] Failed to extract text from "${item.item_key}":`,
|
||||||
|
err instanceof Error ? err.message : err
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI descriptions for all media items in a directory within a mixed library.
|
||||||
|
* Returns the number of items processed.
|
||||||
|
*/
|
||||||
|
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
|
||||||
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
const describeModel = config.modelDescribe || config.model
|
||||||
|
if (!config.endpoint || !describeModel) {
|
||||||
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
if (library.type !== 'mixed') {
|
||||||
|
throw Object.assign(new Error('Description generation is only available for mixed libraries'), { code: 'INVALID_TYPE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const prefix = dirPath
|
||||||
|
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
|
||||||
|
: `${libraryId}:mixed_file:`
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key LIKE ? AND item_type = ?')
|
||||||
|
.all(`${prefix}%`, 'mixed_file') as MediaItemRow[]
|
||||||
|
|
||||||
|
let processed = 0
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.file_path) continue
|
||||||
|
const ext = path.extname(item.file_path).toLowerCase()
|
||||||
|
if (!IMAGE_EXTENSIONS.has(ext) && !VIDEO_EXTENSIONS.has(ext)) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
await generateItemDescription(item.item_key)
|
||||||
|
processed++
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[ai-tagger] Failed to describe "${item.item_key}":`,
|
||||||
|
err instanceof Error ? err.message : err
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the AI fields (description, extracted text, translation) for a media item.
|
||||||
|
*/
|
||||||
|
export function getAiFields(itemKey: string): { aiDescription: string | null; extractedText: string | null; extractedTextTranslated: string | null } {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT ai_description, extracted_text, extracted_text_translated FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as { ai_description: string | null; extracted_text: string | null; extracted_text_translated: string | null } | undefined
|
||||||
|
if (!row) {
|
||||||
|
return { aiDescription: null, extractedText: null, extractedTextTranslated: null }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
aiDescription: row.ai_description,
|
||||||
|
extractedText: row.extracted_text,
|
||||||
|
extractedTextTranslated: row.extracted_text_translated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,21 +39,246 @@ export function setScanLastRan(ts: number): void {
|
|||||||
|
|
||||||
// ─── AI Settings ─────────────────────────────────────────────────────────────
|
// ─── AI Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AiConfig {
|
const DEFAULT_PROMPT_DESCRIBE =
|
||||||
|
'Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.'
|
||||||
|
const DEFAULT_PROMPT_TAGGER = ''
|
||||||
|
const DEFAULT_PROMPT_EXTRACT =
|
||||||
|
'Be mindful of different colors of text that may indicate different speakers or emphasis.'
|
||||||
|
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
|
||||||
|
|
||||||
|
export type OcrMode = 'hybrid' | 'tesseract' | 'llm'
|
||||||
|
|
||||||
|
export interface AiConfig {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
model: string
|
model: string
|
||||||
|
modelTagging: string
|
||||||
|
modelDescribe: string
|
||||||
|
modelExtract: string
|
||||||
|
modelTranslate: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
promptDescribe: string
|
||||||
|
promptTagger: string
|
||||||
|
promptExtract: string
|
||||||
|
promptTranslate: string
|
||||||
|
maxTokensTag: number
|
||||||
|
maxTokensDescribe: number
|
||||||
|
maxTokensExtract: number
|
||||||
|
maxTokensTranslate: number
|
||||||
|
ocrMode: OcrMode
|
||||||
|
ocrLanguages: string
|
||||||
|
ocrConfidenceThreshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAiConfig(): AiConfig {
|
export function getAiConfig(): AiConfig {
|
||||||
const endpoint = getSetting('ai_endpoint') ?? ''
|
const endpoint = getSetting('ai_endpoint') ?? ''
|
||||||
const model = getSetting('ai_model') ?? ''
|
const model = getSetting('ai_model') ?? ''
|
||||||
|
const modelTagging = getSetting('ai_model_tagging') ?? ''
|
||||||
|
const modelDescribe = getSetting('ai_model_describe') ?? ''
|
||||||
|
const modelExtract = getSetting('ai_model_extract') ?? ''
|
||||||
|
const modelTranslate = getSetting('ai_model_translate') ?? ''
|
||||||
const enabled = getSetting('ai_enabled') === 'true'
|
const enabled = getSetting('ai_enabled') === 'true'
|
||||||
return { endpoint, model, enabled }
|
const promptDescribeRaw = getSetting('ai_prompt_describe')
|
||||||
|
const promptDescribe = promptDescribeRaw !== null ? promptDescribeRaw : DEFAULT_PROMPT_DESCRIBE
|
||||||
|
const promptTaggerRaw = getSetting('ai_prompt_tagger')
|
||||||
|
const promptTagger = promptTaggerRaw !== null ? promptTaggerRaw : DEFAULT_PROMPT_TAGGER
|
||||||
|
const promptExtractRaw = getSetting('ai_prompt_extract')
|
||||||
|
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
|
||||||
|
const promptTranslateRaw = getSetting('ai_prompt_translate')
|
||||||
|
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
|
||||||
|
const maxTokensTag = parseInt(getSetting('ai_max_tokens_tag') ?? '8192', 10) || 8192
|
||||||
|
const maxTokensDescribe = parseInt(getSetting('ai_max_tokens_describe') ?? '8192', 10) || 8192
|
||||||
|
const maxTokensExtract = parseInt(getSetting('ai_max_tokens_extract') ?? '8192', 10) || 8192
|
||||||
|
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
|
||||||
|
const rawOcrMode = getSetting('ai_ocr_mode') ?? 'hybrid'
|
||||||
|
const ocrMode: OcrMode = rawOcrMode === 'tesseract' || rawOcrMode === 'llm' ? rawOcrMode : 'hybrid'
|
||||||
|
const ocrLanguages = getSetting('ai_ocr_languages') ?? 'eng'
|
||||||
|
const ocrConfidenceThreshold = parseInt(getSetting('ai_ocr_confidence_threshold') ?? '70', 10) || 70
|
||||||
|
return {
|
||||||
|
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
|
||||||
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
|
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
|
||||||
|
ocrMode, ocrLanguages, ocrConfidenceThreshold,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAiConfig(endpoint: string, model: string, enabled: boolean): void {
|
export function updateAiConfig(
|
||||||
|
endpoint: string,
|
||||||
|
model: string,
|
||||||
|
enabled: boolean,
|
||||||
|
modelTagging?: string,
|
||||||
|
modelDescribe?: string,
|
||||||
|
modelExtract?: string,
|
||||||
|
modelTranslate?: string,
|
||||||
|
promptDescribe?: string,
|
||||||
|
promptTagger?: string,
|
||||||
|
promptExtract?: string,
|
||||||
|
promptTranslate?: string,
|
||||||
|
maxTokensTag?: number,
|
||||||
|
maxTokensDescribe?: number,
|
||||||
|
maxTokensExtract?: number,
|
||||||
|
maxTokensTranslate?: number,
|
||||||
|
ocrMode?: OcrMode,
|
||||||
|
ocrLanguages?: string,
|
||||||
|
ocrConfidenceThreshold?: number,
|
||||||
|
): void {
|
||||||
setSetting('ai_endpoint', endpoint)
|
setSetting('ai_endpoint', endpoint)
|
||||||
setSetting('ai_model', model)
|
setSetting('ai_model', model)
|
||||||
setSetting('ai_enabled', enabled ? 'true' : 'false')
|
setSetting('ai_enabled', enabled ? 'true' : 'false')
|
||||||
|
if (modelTagging !== undefined) setSetting('ai_model_tagging', modelTagging)
|
||||||
|
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
|
||||||
|
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
|
||||||
|
if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
|
||||||
|
if (promptDescribe !== undefined) setSetting('ai_prompt_describe', promptDescribe)
|
||||||
|
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
|
||||||
|
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
|
||||||
|
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
|
||||||
|
if (maxTokensTag !== undefined) setSetting('ai_max_tokens_tag', String(Math.max(1, Math.floor(maxTokensTag))))
|
||||||
|
if (maxTokensDescribe !== undefined) setSetting('ai_max_tokens_describe', String(Math.max(1, Math.floor(maxTokensDescribe))))
|
||||||
|
if (maxTokensExtract !== undefined) setSetting('ai_max_tokens_extract', String(Math.max(1, Math.floor(maxTokensExtract))))
|
||||||
|
if (maxTokensTranslate !== undefined) setSetting('ai_max_tokens_translate', String(Math.max(1, Math.floor(maxTokensTranslate))))
|
||||||
|
if (ocrMode !== undefined) setSetting('ai_ocr_mode', ocrMode)
|
||||||
|
if (ocrLanguages !== undefined) setSetting('ai_ocr_languages', ocrLanguages.trim() || 'eng')
|
||||||
|
if (ocrConfidenceThreshold !== undefined) setSetting('ai_ocr_confidence_threshold', String(Math.max(0, Math.min(100, Math.floor(ocrConfidenceThreshold)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreferredLanguage(): string {
|
||||||
|
return getSetting('preferred_language') ?? 'English'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPreferredLanguage(language: string): void {
|
||||||
|
setSetting('preferred_language', language)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-library AI overrides ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LibraryAiOverrides {
|
||||||
|
modelTagging: string
|
||||||
|
modelDescribe: string
|
||||||
|
modelExtract: string
|
||||||
|
modelTranslate: string
|
||||||
|
promptDescribe: string
|
||||||
|
promptTagger: string
|
||||||
|
promptExtract: string
|
||||||
|
promptTranslate: string
|
||||||
|
maxTokensTag: number | null
|
||||||
|
maxTokensDescribe: number | null
|
||||||
|
maxTokensExtract: number | null
|
||||||
|
maxTokensTranslate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibraryAiSettingsRow {
|
||||||
|
model_tagging: string | null
|
||||||
|
model_describe: string | null
|
||||||
|
model_extract: string | null
|
||||||
|
model_translate: string | null
|
||||||
|
prompt_describe: string | null
|
||||||
|
prompt_tagger: string | null
|
||||||
|
prompt_extract: string | null
|
||||||
|
prompt_translate: string | null
|
||||||
|
max_tokens_tag: number | null
|
||||||
|
max_tokens_describe: number | null
|
||||||
|
max_tokens_extract: number | null
|
||||||
|
max_tokens_translate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM library_ai_settings WHERE library_id = ?')
|
||||||
|
.get(libraryId) as LibraryAiSettingsRow | undefined
|
||||||
|
return {
|
||||||
|
modelTagging: row?.model_tagging ?? '',
|
||||||
|
modelDescribe: row?.model_describe ?? '',
|
||||||
|
modelExtract: row?.model_extract ?? '',
|
||||||
|
modelTranslate: row?.model_translate ?? '',
|
||||||
|
promptDescribe: row?.prompt_describe ?? '',
|
||||||
|
promptTagger: row?.prompt_tagger ?? '',
|
||||||
|
promptExtract: row?.prompt_extract ?? '',
|
||||||
|
promptTranslate: row?.prompt_translate ?? '',
|
||||||
|
maxTokensTag: row?.max_tokens_tag ?? null,
|
||||||
|
maxTokensDescribe: row?.max_tokens_describe ?? null,
|
||||||
|
maxTokensExtract: row?.max_tokens_extract ?? null,
|
||||||
|
maxTokensTranslate: row?.max_tokens_translate ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLibraryAiOverrides(libraryId: string, overrides: Partial<LibraryAiOverrides>): void {
|
||||||
|
const db = getDb()
|
||||||
|
// Ensure a row exists
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
|
||||||
|
).run(libraryId)
|
||||||
|
|
||||||
|
const stringFields: Record<string, string | undefined> = {
|
||||||
|
model_tagging: overrides.modelTagging,
|
||||||
|
model_describe: overrides.modelDescribe,
|
||||||
|
model_extract: overrides.modelExtract,
|
||||||
|
model_translate: overrides.modelTranslate,
|
||||||
|
prompt_describe: overrides.promptDescribe,
|
||||||
|
prompt_tagger: overrides.promptTagger,
|
||||||
|
prompt_extract: overrides.promptExtract,
|
||||||
|
prompt_translate: overrides.promptTranslate,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [col, val] of Object.entries(stringFields)) {
|
||||||
|
if (val !== undefined) {
|
||||||
|
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
|
||||||
|
val === '' ? null : val,
|
||||||
|
libraryId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFields: Record<string, number | null | undefined> = {
|
||||||
|
max_tokens_tag: overrides.maxTokensTag,
|
||||||
|
max_tokens_describe: overrides.maxTokensDescribe,
|
||||||
|
max_tokens_extract: overrides.maxTokensExtract,
|
||||||
|
max_tokens_translate: overrides.maxTokensTranslate,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [col, val] of Object.entries(numberFields)) {
|
||||||
|
if (val !== undefined) {
|
||||||
|
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
|
||||||
|
val === null ? null : Math.max(1, Math.floor(val)),
|
||||||
|
libraryId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveAiConfig(libraryId: string): AiConfig {
|
||||||
|
const global = getAiConfig()
|
||||||
|
const overrides = getLibraryAiOverrides(libraryId)
|
||||||
|
return {
|
||||||
|
endpoint: global.endpoint,
|
||||||
|
model: global.model,
|
||||||
|
enabled: global.enabled,
|
||||||
|
modelTagging: overrides.modelTagging || global.modelTagging,
|
||||||
|
modelDescribe: overrides.modelDescribe || global.modelDescribe,
|
||||||
|
modelExtract: overrides.modelExtract || global.modelExtract,
|
||||||
|
modelTranslate: overrides.modelTranslate || global.modelTranslate,
|
||||||
|
promptDescribe: overrides.promptDescribe || global.promptDescribe,
|
||||||
|
promptTagger: overrides.promptTagger || global.promptTagger,
|
||||||
|
promptExtract: overrides.promptExtract || global.promptExtract,
|
||||||
|
promptTranslate: overrides.promptTranslate || global.promptTranslate,
|
||||||
|
maxTokensTag: overrides.maxTokensTag ?? global.maxTokensTag,
|
||||||
|
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
|
||||||
|
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
|
||||||
|
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
|
||||||
|
ocrMode: global.ocrMode,
|
||||||
|
ocrLanguages: global.ocrLanguages,
|
||||||
|
ocrConfidenceThreshold: global.ocrConfidenceThreshold,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI Max Retries ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getAiMaxRetries(): number {
|
||||||
|
const raw = getSetting('ai_max_retries')
|
||||||
|
const parsed = parseInt(raw ?? '3', 10)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAiMaxRetries(n: number): void {
|
||||||
|
setSetting('ai_max_retries', String(Math.max(0, Math.floor(n))))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth guard result type
|
// Auth guard result type
|
||||||
type AuthSuccess = { session: IronSession<SessionData> }
|
type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' }
|
||||||
type AuthResult = AuthSuccess | NextResponse
|
type AuthResult = AuthSuccess | NextResponse
|
||||||
|
|
||||||
// Read-only session from an API route request (throwaway response)
|
// Read-only session from an API route request (throwaway response)
|
||||||
@@ -100,13 +100,22 @@ export async function requireLibraryAccess(req: NextRequest, libraryId: string):
|
|||||||
if (!session.userId) {
|
if (!session.userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
if (session.role === 'admin') return { session }
|
if (session.role === 'admin') return { session, accessLevel: 'admin' }
|
||||||
|
|
||||||
// Lazy import to avoid pulling DB into edge contexts
|
// Lazy import to avoid pulling DB into edge contexts
|
||||||
const { getPermittedLibraryIds } = await import('./users')
|
const { getLibraryAccessLevel } = await import('./users')
|
||||||
const permitted = getPermittedLibraryIds(session.userId)
|
const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
|
||||||
if (!permitted.includes(libraryId)) {
|
if (!accessLevel) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
return { session }
|
return { session, accessLevel }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
|
||||||
|
const result = await requireLibraryAccess(req, libraryId)
|
||||||
|
if (result instanceof NextResponse) return result
|
||||||
|
if (result.accessLevel === 'read') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateMediaItemsFingerprint(db)
|
migrateMediaItemsFingerprint(db)
|
||||||
migrateMediaTagsToItemKey(db)
|
migrateMediaTagsToItemKey(db)
|
||||||
migrateMediaItemsAiTagged(db)
|
migrateMediaItemsAiTagged(db)
|
||||||
|
migrateMediaItemsAiFields(db)
|
||||||
|
migrateLibraryAiSettings(db)
|
||||||
|
migrateAiJobs(db)
|
||||||
|
migrateLibraryPermissionsAccessLevel(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +118,12 @@ function seedAppSettings(db: Database.Database): void {
|
|||||||
ai_enabled: 'false',
|
ai_enabled: 'false',
|
||||||
ai_endpoint: '',
|
ai_endpoint: '',
|
||||||
ai_model: '',
|
ai_model: '',
|
||||||
|
preferred_language: 'English',
|
||||||
|
ai_max_retries: '3',
|
||||||
|
ai_max_tokens_tag: '8192',
|
||||||
|
ai_max_tokens_describe: '8192',
|
||||||
|
ai_max_tokens_extract: '8192',
|
||||||
|
ai_max_tokens_translate: '8192',
|
||||||
}
|
}
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
|
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
|
||||||
@@ -241,6 +251,51 @@ function migrateMediaItemsAiTagged(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateMediaItemsAiFields(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (!row) return
|
||||||
|
if (!row.sql.includes('ai_description')) {
|
||||||
|
db.exec('ALTER TABLE media_items ADD COLUMN ai_description TEXT')
|
||||||
|
}
|
||||||
|
if (!row.sql.includes('extracted_text')) {
|
||||||
|
db.exec('ALTER TABLE media_items ADD COLUMN extracted_text TEXT')
|
||||||
|
}
|
||||||
|
if (!row.sql.includes('extracted_text_translated')) {
|
||||||
|
db.exec('ALTER TABLE media_items ADD COLUMN extracted_text_translated TEXT')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLibraryAiSettings(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS library_ai_settings (
|
||||||
|
library_id TEXT PRIMARY KEY REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
model_tagging TEXT,
|
||||||
|
model_describe TEXT,
|
||||||
|
model_extract TEXT,
|
||||||
|
model_translate TEXT,
|
||||||
|
prompt_describe TEXT,
|
||||||
|
prompt_tagger TEXT,
|
||||||
|
prompt_extract TEXT,
|
||||||
|
prompt_translate TEXT
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Add max_tokens columns if they don't exist yet
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_ai_settings'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (row && !row.sql.includes('max_tokens_tag')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_tag INTEGER;
|
||||||
|
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_describe INTEGER;
|
||||||
|
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_extract INTEGER;
|
||||||
|
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_translate INTEGER;
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function migrateLibrariesType(db: Database.Database): void {
|
function migrateLibrariesType(db: Database.Database): void {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||||
@@ -263,3 +318,42 @@ 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 {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
item_key TEXT NOT NULL,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
job_type TEXT NOT NULL CHECK(job_type IN ('tag','describe','extract','translate')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','completed','failed')),
|
||||||
|
error TEXT,
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
started_at INTEGER,
|
||||||
|
completed_at INTEGER,
|
||||||
|
item_title TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Add payload column if not present
|
||||||
|
const aiJobsRow = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (aiJobsRow && !aiJobsRow.sql.includes('payload')) {
|
||||||
|
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export function getCategories(): TagCategory[] {
|
|||||||
return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[]
|
return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the distinct category IDs that have at least one tag assigned to any
|
||||||
|
* item in the given library. Used by the AI tagger to restrict the tag prompt
|
||||||
|
* to categories that are actually in use within the target library.
|
||||||
|
*/
|
||||||
|
export function getActiveCategoryIdsForLibrary(libraryId: string): string[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT t.category_id
|
||||||
|
FROM tags t
|
||||||
|
JOIN media_tags mt ON mt.tag_id = t.id
|
||||||
|
JOIN media_items mi ON mi.item_key = mt.item_key
|
||||||
|
WHERE mi.library_id = ?`
|
||||||
|
)
|
||||||
|
.all(libraryId) as { category_id: string }[]
|
||||||
|
return rows.map((r) => r.category_id)
|
||||||
|
}
|
||||||
|
|
||||||
export function addCategory(name: string): TagCategory {
|
export function addCategory(name: string): TagCategory {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) throw new Error('Category name is required.')
|
if (!trimmed) throw new Error('Category name is required.')
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import sharp from 'sharp'
|
|||||||
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
||||||
const THUMBNAIL_WIDTH = 400
|
const THUMBNAIL_WIDTH = 400
|
||||||
const JPEG_QUALITY = 75
|
const JPEG_QUALITY = 75
|
||||||
|
const AI_IMAGE_WIDTH = 1920
|
||||||
|
const AI_JPEG_QUALITY = 90
|
||||||
|
|
||||||
/** Ensure the cache directory exists. */
|
/** Ensure the cache directory exists. */
|
||||||
function ensureCacheDir(): void {
|
function ensureCacheDir(): void {
|
||||||
@@ -47,6 +49,30 @@ async function generateImageThumbnail(src: string, dest: string): Promise<void>
|
|||||||
fs.renameSync(tmp, dest)
|
fs.renameSync(tmp, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate a high-resolution JPEG for AI vision use. Images smaller than
|
||||||
|
* AI_IMAGE_WIDTH are not upscaled — they are converted at their native size. */
|
||||||
|
async function generateAiImage(src: string, dest: string): Promise<void> {
|
||||||
|
const tmp = dest + '.tmp'
|
||||||
|
await sharp(src)
|
||||||
|
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: AI_JPEG_QUALITY })
|
||||||
|
.toFile(tmp)
|
||||||
|
fs.renameSync(tmp, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a grayscale, contrast-normalised PNG for local OCR (Tesseract).
|
||||||
|
* PNG is lossless and avoids JPEG artefacts that can degrade OCR accuracy. */
|
||||||
|
async function generateOcrImage(src: string, dest: string): Promise<void> {
|
||||||
|
const tmp = dest + '.tmp'
|
||||||
|
await sharp(src)
|
||||||
|
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
|
||||||
|
.grayscale()
|
||||||
|
.normalise()
|
||||||
|
.png()
|
||||||
|
.toFile(tmp)
|
||||||
|
fs.renameSync(tmp, dest)
|
||||||
|
}
|
||||||
|
|
||||||
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
|
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
|
||||||
function run(bin: string, args: string[]): Promise<void> {
|
function run(bin: string, args: string[]): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -87,22 +113,13 @@ async function getVideoDuration(src: string): Promise<number> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a thumbnail from a video using ffmpeg. */
|
/** Extract a single frame from a video at the given offset (seconds) and write to dest. */
|
||||||
async function generateVideoThumbnail(src: string, dest: string): Promise<void> {
|
async function generateVideoFrameAtOffset(src: string, dest: string, offsetSeconds: number): Promise<void> {
|
||||||
const tmp = dest + '.tmp'
|
const tmp = dest + '.tmp'
|
||||||
|
|
||||||
// Seek to 10% of the video duration for a representative frame
|
|
||||||
let offset = 0
|
|
||||||
try {
|
|
||||||
const duration = await getVideoDuration(src)
|
|
||||||
offset = Math.max(0, duration * 0.1)
|
|
||||||
} catch {
|
|
||||||
// If ffprobe fails, fall back to seeking to 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'-y', // overwrite output
|
'-y', // overwrite output
|
||||||
'-ss', String(offset), // seek before input (fast)
|
'-ss', String(offsetSeconds), // seek before input (fast)
|
||||||
'-i', src,
|
'-i', src,
|
||||||
'-frames:v', '1',
|
'-frames:v', '1',
|
||||||
'-q:v', '5',
|
'-q:v', '5',
|
||||||
@@ -115,6 +132,95 @@ async function generateVideoThumbnail(src: string, dest: string): Promise<void>
|
|||||||
fs.renameSync(tmp, dest)
|
fs.renameSync(tmp, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate a thumbnail from a video using ffmpeg (seeks to 10% of duration). */
|
||||||
|
async function generateVideoThumbnail(src: string, dest: string): Promise<void> {
|
||||||
|
let offset = 0
|
||||||
|
try {
|
||||||
|
const duration = await getVideoDuration(src)
|
||||||
|
offset = Math.max(0, duration * 0.1)
|
||||||
|
} catch {
|
||||||
|
// If ffprobe fails, fall back to seeking to 0
|
||||||
|
}
|
||||||
|
await generateVideoFrameAtOffset(src, dest, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract frames from a video at each given percentage of its duration.
|
||||||
|
* Returns the absolute paths to the cached frame JPEGs, in the same order as `percentages`.
|
||||||
|
* Uses a per-frame cache key so each frame is cached independently.
|
||||||
|
*/
|
||||||
|
export async function getVideoFramePaths(
|
||||||
|
absoluteFilePath: string,
|
||||||
|
libraryId: string,
|
||||||
|
percentages: number[]
|
||||||
|
): Promise<string[]> {
|
||||||
|
ensureCacheDir()
|
||||||
|
|
||||||
|
let duration = 0
|
||||||
|
try {
|
||||||
|
duration = await getVideoDuration(absoluteFilePath)
|
||||||
|
} catch {
|
||||||
|
// Fall back to 0; all frames will seek to position 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const framePaths: string[] = []
|
||||||
|
|
||||||
|
for (const pct of percentages) {
|
||||||
|
const offset = Math.max(0, duration * pct)
|
||||||
|
const key = crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(libraryId + ':' + absoluteFilePath + ':' + pct)
|
||||||
|
.digest('hex')
|
||||||
|
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
|
||||||
|
|
||||||
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||||
|
if (!cached) {
|
||||||
|
await generateVideoFrameAtOffset(absoluteFilePath, cacheFile, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
framePaths.push(cacheFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return framePaths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute path to a high-resolution JPEG suitable for AI vision
|
||||||
|
* APIs (1920px wide max, quality 90). Cached alongside display thumbnails with
|
||||||
|
* an `_ai` suffix so display performance is unaffected.
|
||||||
|
* Generates on first call or when the source file has been modified.
|
||||||
|
*/
|
||||||
|
export async function getAiImagePath(
|
||||||
|
absoluteFilePath: string,
|
||||||
|
libraryId: string
|
||||||
|
): Promise<string> {
|
||||||
|
ensureCacheDir()
|
||||||
|
const key = cacheKey(libraryId, absoluteFilePath)
|
||||||
|
const cacheFile = path.join(CACHE_DIR, key + '_ai.jpg')
|
||||||
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||||
|
if (cached) return cached
|
||||||
|
await generateAiImage(absoluteFilePath, cacheFile)
|
||||||
|
return cacheFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute path to a preprocessed PNG suitable for local OCR.
|
||||||
|
* The image is converted to grayscale and contrast-normalised for better
|
||||||
|
* Tesseract accuracy. Cached with an `_ocr` suffix.
|
||||||
|
*/
|
||||||
|
export async function getOcrImagePath(
|
||||||
|
absoluteFilePath: string,
|
||||||
|
libraryId: string
|
||||||
|
): Promise<string> {
|
||||||
|
ensureCacheDir()
|
||||||
|
const key = cacheKey(libraryId, absoluteFilePath)
|
||||||
|
const cacheFile = path.join(CACHE_DIR, key + '_ocr.png')
|
||||||
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||||
|
if (cached) return cached
|
||||||
|
await generateOcrImage(absoluteFilePath, cacheFile)
|
||||||
|
return cacheFile
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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