Compare commits

..

2 Commits

Author SHA1 Message Date
Garret Patti
ce6803b0e0 Merge branch 'main' of http://gitea.lan/gpatti/MediaLore
Some checks failed
Build and Push Docker Image / build (push) Failing after 15s
2026-04-13 13:00:13 -04:00
Garret Patti
23989beec4 fix translation 2026-04-13 12:56:37 -04:00
46 changed files with 1765 additions and 2934 deletions

1
.gitignore vendored
View File

@@ -10,4 +10,3 @@ medialore.db-wal
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.session_secret .session_secret
.vscode/ .vscode/
*.traineddata

View File

@@ -45,11 +45,6 @@ COPY --from=builder /app/.next/static ./.next/static
COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
COPY --from=deps /app/node_modules/sharp ./node_modules/sharp COPY --from=deps /app/node_modules/sharp ./node_modules/sharp
COPY --from=deps /app/node_modules/@img ./node_modules/@img COPY --from=deps /app/node_modules/@img ./node_modules/@img
# tesseract.js loads its worker via worker_threads using a runtime-constructed path,
# so the standalone file tracer never discovers src/worker-script/node/. Copy the
# full package so that path resolves correctly at runtime.
COPY --from=deps /app/node_modules/tesseract.js ./node_modules/tesseract.js
COPY --from=deps /app/node_modules/tesseract.js-core ./node_modules/tesseract.js-core
# Create thumbnail cache directory (mounted as a volume in production) # Create thumbnail cache directory (mounted as a volume in production)
RUN mkdir -p /app/.thumbnails RUN mkdir -p /app/.thumbnails

View File

@@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
serverExternalPackages: ['better-sqlite3', 'sharp', 'tesseract.js'], serverExternalPackages: ['better-sqlite3', 'sharp'],
} }
export default nextConfig export default nextConfig

117
package-lock.json generated
View File

@@ -17,8 +17,7 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sharp": "^0.34.5", "sharp": "^0.34.5"
"tesseract.js": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
@@ -2951,12 +2950,6 @@
"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",
@@ -4810,12 +4803,6 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5301,12 +5288,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -6186,26 +6167,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -6354,15 +6315,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6795,12 +6747,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -7639,30 +7585,6 @@
"streamx": "^2.12.5" "streamx": "^2.12.5"
} }
}, },
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/text-decoder": { "node_modules/text-decoder": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -7733,12 +7655,6 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -8039,28 +7955,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8343,15 +8237,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",

View File

@@ -20,8 +20,7 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sharp": "^0.34.5", "sharp": "^0.34.5"
"tesseract.js": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",

View File

@@ -38,10 +38,6 @@ export async function PUT(
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined, promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined, promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined, promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
maxTokensTag: typeof body.maxTokensTag === 'number' ? body.maxTokensTag : (body.maxTokensTag === null ? null : undefined),
maxTokensDescribe: typeof body.maxTokensDescribe === 'number' ? body.maxTokensDescribe : (body.maxTokensDescribe === null ? null : undefined),
maxTokensExtract: typeof body.maxTokensExtract === 'number' ? body.maxTokensExtract : (body.maxTokensExtract === null ? null : undefined),
maxTokensTranslate: typeof body.maxTokensTranslate === 'number' ? body.maxTokensTranslate : (body.maxTokensTranslate === null ? null : undefined),
}) })
return NextResponse.json(getLibraryAiOverrides(id)) return NextResponse.json(getLibraryAiOverrides(id))

View File

@@ -1,11 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getAiConfig } from '@/lib/app-settings'
export async function GET(request: NextRequest) {
const auth = await requireAuth(request)
if (auth instanceof NextResponse) return auth
const { ocrMode, ocrLanguages } = getAiConfig()
return NextResponse.json({ ocrMode, ocrLanguages })
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth' import { requireAdmin } from '@/lib/auth'
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries, type OcrMode } from '@/lib/app-settings' import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries } from '@/lib/app-settings'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = await requireAdmin(request) const auth = await requireAdmin(request)
@@ -30,13 +30,6 @@ export async function PUT(request: NextRequest) {
promptExtract?: string promptExtract?: string
promptTranslate?: string promptTranslate?: string
maxRetries?: number maxRetries?: number
maxTokensTag?: number
maxTokensDescribe?: number
maxTokensExtract?: number
maxTokensTranslate?: number
ocrMode?: string
ocrLanguages?: string
ocrConfidenceThreshold?: number
} }
try { try {
body = await request.json() body = await request.json()
@@ -49,8 +42,6 @@ export async function PUT(request: NextRequest) {
modelTagging, modelDescribe, modelExtract, modelTranslate, modelTagging, modelDescribe, modelExtract, modelTranslate,
promptDescribe, promptTagger, promptExtract, promptTranslate, promptDescribe, promptTagger, promptExtract, promptTranslate,
maxRetries, maxRetries,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
ocrMode, ocrLanguages, ocrConfidenceThreshold,
} = body } = body
if (typeof endpoint !== 'string') { if (typeof endpoint !== 'string') {
@@ -75,13 +66,6 @@ export async function PUT(request: NextRequest) {
typeof promptTagger === 'string' ? promptTagger : undefined, typeof promptTagger === 'string' ? promptTagger : undefined,
typeof promptExtract === 'string' ? promptExtract : undefined, typeof promptExtract === 'string' ? promptExtract : undefined,
typeof promptTranslate === 'string' ? promptTranslate : undefined, typeof promptTranslate === 'string' ? promptTranslate : undefined,
typeof maxTokensTag === 'number' ? maxTokensTag : undefined,
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
(ocrMode === 'hybrid' || ocrMode === 'tesseract' || ocrMode === 'llm') ? (ocrMode as OcrMode) : undefined,
typeof ocrLanguages === 'string' ? ocrLanguages : undefined,
typeof ocrConfidenceThreshold === 'number' ? ocrConfidenceThreshold : undefined,
) )
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) { if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs' import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
} }
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS) const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'describe', libraryId) const jobId = enqueueJob(itemKey, 'describe', libraryId)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs' import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
} }
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS) const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)

View File

@@ -1,33 +1,24 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string } let body: { itemKey?: string }
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
const { itemKey, ocrLanguages, ocrMode } = body const { itemKey } = body
if (!itemKey || typeof itemKey !== 'string') { if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const payload: Record<string, string> = {} const jobId = enqueueJob(itemKey, 'extract', libraryId)
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
if (ocrMode) payload.ocrMode = ocrMode
const jobId = enqueueJob(
itemKey,
'extract',
libraryId,
undefined,
Object.keys(payload).length ? payload : undefined,
)
return NextResponse.json({ jobId }, { status: 202 }) return NextResponse.json({ jobId }, { status: 202 })
} }

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger' import { getAiFields, updateExtractedText } from '@/lib/ai-tagger'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
@@ -19,37 +19,25 @@ export async function GET(request: NextRequest) {
} }
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
let body: { itemKey?: string; extractedText?: string; aiDescription?: string } let body: { itemKey?: string; extractedText?: string }
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
const { itemKey, extractedText, aiDescription } = body const { itemKey, extractedText } = body
if (!itemKey || typeof itemKey !== 'string') { if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
} }
if (extractedText === undefined && aiDescription === undefined) { if (typeof extractedText !== 'string') {
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 }) return NextResponse.json({ error: 'extractedText is required' }, { status: 400 })
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
if (extractedText !== undefined) { updateExtractedText(itemKey, extractedText)
if (typeof extractedText !== 'string') {
return NextResponse.json({ error: 'extractedText must be a string' }, { status: 400 })
}
updateExtractedText(itemKey, extractedText)
}
if (aiDescription !== undefined) {
if (typeof aiDescription !== 'string') {
return NextResponse.json({ error: 'aiDescription must be a string' }, { status: 400 })
}
updateAiDescription(itemKey, aiDescription)
}
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'tag', libraryId) const jobId = enqueueJob(itemKey, 'tag', libraryId)

View File

@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
import { getDb } from '@/lib/db'
export async function POST(request: NextRequest) {
let body: { libraryId?: string; path?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { libraryId, path: dirPath } = body
if (!libraryId || typeof libraryId !== 'string') {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()
const prefix = dirPath
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
: `${libraryId}:mixed_file:`
// Only enqueue translate jobs for items that already have extracted text
const items = db
.prepare(
'SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = ? AND extracted_text IS NOT NULL'
)
.all(`${prefix}%`, 'mixed_file') as { item_key: string }[]
const jobIds = items.map(({ item_key }) => enqueueJob(item_key, 'translate', libraryId))
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined) const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)

View File

@@ -1,5 +1,4 @@
import fs from 'fs' import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
@@ -32,40 +31,6 @@ 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)
} }

View File

@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
try { try {
const libraries = const libraries =
session.role === 'admin' session.role === 'admin'
? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' })) ? getLibraries()
: getLibrariesForUser(session.userId, session.role) : getLibrariesForUser(session.userId, session.role)
return NextResponse.json(libraries) return NextResponse.json(libraries)
} catch (err) { } catch (err) {

View File

@@ -120,46 +120,7 @@ export async function POST(request: NextRequest) {
status: nfo.status ?? null, status: nfo.status ?? null,
}), }),
}) })
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year })
// Optionally also refresh every episode NFO in this series
let episodesUpdated = 0
const includeEpisodes = searchParams.get('includeEpisodes') === 'true'
if (includeEpisodes) {
type EpRow = { item_key: string; file_path: string | null; metadata: string | null }
const episodeRows = db
.prepare(`SELECT item_key, file_path, metadata FROM media_items WHERE item_type = 'tv_episode' AND item_key LIKE ?`)
.all(`${libraryId}:tv_episode:${encodedDirName}:%`) as EpRow[]
const updateEp = db.prepare(`
UPDATE media_items SET title = @title, plot = @plot, metadata = @metadata WHERE item_key = @item_key
`)
db.transaction(() => {
for (const ep of episodeRows) {
if (!ep.file_path) continue
const epDir = path.join(libraryRoot, path.dirname(ep.file_path))
const baseName = path.basename(ep.file_path, path.extname(ep.file_path))
const epNfo = parseEpisodeNfo(path.join(epDir, `${baseName}.nfo`))
if (!epNfo) continue
const epMeta = ep.metadata ? JSON.parse(ep.metadata) : {}
updateEp.run({
item_key: ep.item_key,
title: epNfo.title ?? null,
plot: epNfo.plot ?? null,
metadata: JSON.stringify({
...epMeta,
episodeNumber: epNfo.episode ?? epMeta.episodeNumber ?? null,
seasonNumber: epNfo.season ?? epMeta.seasonNumber ?? null,
aired: epNfo.aired ?? null,
rating: epNfo.rating ?? null,
}),
})
episodesUpdated++
}
})()
}
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year, episodesUpdated })
} }
if (itemType === 'tv_episode') { if (itemType === 'tv_episode') {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags' import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
function extractLibraryId(itemKey: string): string | null { function extractLibraryId(itemKey: string): string | null {
const colonIdx = itemKey.indexOf(':') const colonIdx = itemKey.indexOf(':')
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
addTagToItem(itemKey, tagId) addTagToItem(itemKey, tagId)
@@ -60,7 +60,7 @@ export async function DELETE(request: NextRequest) {
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryWriteAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
removeTagFromItem(itemKey, tagId) removeTagFromItem(itemKey, tagId)

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth' import { requireAdmin } from '@/lib/auth'
import { getUserById, getLibraryPermissions, setLibraryPermissions, type LibraryPermission } from '@/lib/users' import { getUserById, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users'
import { getLibraries } from '@/lib/libraries' import { getLibraries } from '@/lib/libraries'
export async function GET( export async function GET(
@@ -17,8 +17,8 @@ export async function GET(
return NextResponse.json({ error: 'User not found' }, { status: 404 }) return NextResponse.json({ error: 'User not found' }, { status: 404 })
} }
const permissions = getLibraryPermissions(id) const libraryIds = getPermittedLibraryIds(id)
return NextResponse.json({ permissions }) return NextResponse.json({ libraryIds })
} }
export async function PUT( export async function PUT(
@@ -35,41 +35,24 @@ export async function PUT(
return NextResponse.json({ error: 'User not found' }, { status: 404 }) return NextResponse.json({ error: 'User not found' }, { status: 404 })
} }
let body: { permissions?: unknown } let body: { libraryIds?: unknown }
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
if (!Array.isArray(body.permissions)) { if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) {
return NextResponse.json({ error: 'permissions must be an array' }, { status: 400 }) return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 })
} }
const validAccessLevels = new Set(['read', 'write'])
for (const item of body.permissions) {
if (
typeof item !== 'object' ||
item === null ||
typeof (item as Record<string, unknown>).libraryId !== 'string' ||
!validAccessLevels.has((item as Record<string, unknown>).accessLevel as string)
) {
return NextResponse.json(
{ error: 'Each permission must have libraryId (string) and accessLevel ("read" | "write")' },
{ status: 400 }
)
}
}
const permissions = body.permissions as LibraryPermission[]
const allLibraries = getLibraries() const allLibraries = getLibraries()
const validIds = new Set(allLibraries.map((l) => l.id)) const validIds = new Set(allLibraries.map((l) => l.id))
const invalid = permissions.filter((p) => !validIds.has(p.libraryId)).map((p) => p.libraryId) const invalid = body.libraryIds.filter((id) => !validIds.has(id))
if (invalid.length > 0) { if (invalid.length > 0) {
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 }) return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
} }
setLibraryPermissions(id, permissions) setLibraryPermissions(id, body.libraryIds)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }

View File

@@ -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 { getLibraryAccessLevel } from '@/lib/users' import { getPermittedLibraryIds } 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,41 +23,32 @@ export default async function LibraryPage({ params, searchParams }: Props) {
const library = getLibrary(id) const library = getLibrary(id)
if (!library) notFound() if (!library) notFound()
let readOnly = false
if (session.role !== 'admin') { if (session.role !== 'admin') {
const accessLevel = getLibraryAccessLevel(session.userId, id) const permitted = getPermittedLibraryIds(session.userId)
if (!accessLevel) notFound() if (!permitted.includes(id)) notFound()
readOnly = accessLevel === 'read'
} }
return ( return (
<div> <div>
{library.type !== 'mixed' && ( <div className="flex items-center gap-2 mb-6">
<div className="flex items-center gap-2 mb-6"> <a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}> Libraries
Libraries </a>
</a> <span style={{ color: 'var(--text-secondary)' }}>/</span>
<span style={{ color: 'var(--text-secondary)' }}>/</span> <span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> {library.name}
{library.name} </span>
</span> {session.role === 'admin' && (
{session.role === 'admin' && ( <div className="ml-auto">
<div className="ml-auto"> <ScanLibraryButton libraryId={id} />
<ScanLibraryButton libraryId={id} /> </div>
</div> )}
)} </div>
</div>
)}
{library.type === 'mixed' && session.role === 'admin' && (
<div className="flex justify-end mb-2">
<ScanLibraryButton libraryId={id} />
</div>
)}
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />} {library.type === 'games' && <GamesView libraryId={id} />}
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />} {library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />} {library.type === 'movies' && <MoviesView libraryId={id} />}
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />} {library.type === 'tv' && <TvView libraryId={id} />}
</div> </div>
) )
} }

View File

@@ -16,13 +16,6 @@ interface AiSettings {
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxRetries: number maxRetries: number
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
ocrMode: 'hybrid' | 'tesseract' | 'llm'
ocrLanguages: string
ocrConfidenceThreshold: number
} }
interface AiJob { interface AiJob {
@@ -54,10 +47,6 @@ interface LibraryOverride {
promptTagger: string promptTagger: string
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
} }
function formatElapsed(startedAt: number): string { function formatElapsed(startedAt: number): string {
@@ -78,8 +67,6 @@ export default function AiTaggingPage() {
enabled: false, preferredLanguage: 'English', enabled: false, preferredLanguage: 'English',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '', promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
maxRetries: 3, maxRetries: 3,
maxTokensTag: 8192, maxTokensDescribe: 8192, maxTokensExtract: 8192, maxTokensTranslate: 8192,
ocrMode: 'hybrid', ocrLanguages: 'eng', ocrConfidenceThreshold: 70,
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -309,7 +296,7 @@ export default function AiTaggingPage() {
} }
} }
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string | number | null) => { const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string) => {
setLibraryOverrides((prev) => ({ setLibraryOverrides((prev) => ({
...prev, ...prev,
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value }, [libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
@@ -557,25 +544,6 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Tagging Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensTag}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensTag: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Description Model"> <Field label="Description Model">
<input <input
type="text" type="text"
@@ -593,25 +561,6 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Description Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensDescribe}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensDescribe: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Text Extraction Model"> <Field label="Text Extraction Model">
<input <input
type="text" type="text"
@@ -629,91 +578,6 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Text Extraction Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensExtract}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensExtract: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="OCR Mode">
<div className="flex gap-2">
{(['hybrid', 'tesseract', 'llm'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setSettings((s) => ({ ...s, ocrMode: mode }))}
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{
backgroundColor: settings.ocrMode === mode ? 'var(--accent)' : 'var(--surface)',
color: settings.ocrMode === mode ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
>
{mode === 'hybrid' ? 'Hybrid' : mode === 'tesseract' ? 'Tesseract only' : 'LLM only'}
</button>
))}
</div>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
Hybrid runs local OCR first and falls back to the LLM when confidence is low. Tesseract only never calls the LLM. LLM only uses the original behaviour.
</p>
</Field>
<Field label="OCR Languages">
<input
type="text"
value={settings.ocrLanguages}
onChange={(e) => setSettings((s) => ({ ...s, ocrLanguages: e.target.value }))}
placeholder="eng"
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{`Tesseract language packs to use, joined with '+'. For Japanese manga use jpn+jpn_vert. Language data is downloaded automatically on first use.`}
</p>
</Field>
<Field label="OCR Confidence Threshold">
<input
type="number"
min={0}
max={100}
value={settings.ocrConfidenceThreshold}
onChange={(e) =>
setSettings((s) => ({ ...s, ocrConfidenceThreshold: Math.max(0, Math.min(100, parseInt(e.target.value) || 70)) }))
}
className="w-24 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
In hybrid mode, Tesseract results below this confidence score (0100) fall back to the LLM. Default is 70.
</p>
</Field>
<Field label="Translation Model"> <Field label="Translation Model">
<input <input
type="text" type="text"
@@ -731,25 +595,6 @@ export default function AiTaggingPage() {
/> />
</Field> </Field>
<Field label="Translation Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensTranslate}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensTranslate: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Automatic Tagging"> <Field label="Automatic Tagging">
<label className="flex items-center gap-3 cursor-pointer select-none"> <label className="flex items-center gap-3 cursor-pointer select-none">
<div <div
@@ -1045,7 +890,7 @@ export default function AiTaggingPage() {
<Field key={field} label={label}> <Field key={field} label={label}>
<input <input
type="text" type="text"
value={overrides[field] as string} value={overrides[field]}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)} onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`} placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`}
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2" className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
@@ -1061,39 +906,6 @@ export default function AiTaggingPage() {
))} ))}
</div> </div>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Max Tokens</p>
{(
[
['maxTokensTag', 'Tagging', 'maxTokensTag'] as const,
['maxTokensDescribe', 'Description', 'maxTokensDescribe'] as const,
['maxTokensExtract', 'Text Extraction', 'maxTokensExtract'] as const,
['maxTokensTranslate', 'Translation', 'maxTokensTranslate'] as const,
]
).map(([field, label, globalField]) => (
<Field key={field} label={label}>
<input
type="number"
min={1}
value={overrides[field] ?? ''}
placeholder={`Leave blank to use global default (${settings[globalField]})`}
onChange={(e) => {
const raw = e.target.value
updateLibraryOverride(lib.id, field, raw === '' ? null : Math.max(1, parseInt(raw) || 1))
}}
className="w-40 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
))}
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p> <p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
{( {(
@@ -1107,7 +919,7 @@ export default function AiTaggingPage() {
<Field key={field} label={label}> <Field key={field} label={label}>
<textarea <textarea
rows={3} rows={3}
value={overrides[field] as string} value={overrides[field]}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)} onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'} placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y" className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
@@ -1198,7 +1010,6 @@ function emptyOverride(): LibraryOverride {
return { return {
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '', promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
maxTokensTag: null, maxTokensDescribe: null, maxTokensExtract: null, maxTokensTranslate: null,
} }
} }

View File

@@ -216,39 +216,32 @@ function UserRow({
// ─── Permissions Panel ──────────────────────────────────────────────────────── // ─── Permissions Panel ────────────────────────────────────────────────────────
type AccessLevel = 'none' | 'read' | 'write'
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) { function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
const [levels, setLevels] = useState<Record<string, AccessLevel>>({}) const [permitted, setPermitted] = useState<string[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
useEffect(() => { useEffect(() => {
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`) fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
.then((r) => r.json()) .then((r) => r.json())
.then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => { .then((data: { libraryIds: string[] }) => {
const map: Record<string, AccessLevel> = {} setPermitted(data.libraryIds)
for (const p of data.permissions) {
map[p.libraryId] = p.accessLevel
}
setLevels(map)
setLoaded(true) setLoaded(true)
}) })
}, [userId]) }, [userId])
const setLevel = (libraryId: string, level: AccessLevel) => { const toggle = (libraryId: string) => {
setLevels((prev) => ({ ...prev, [libraryId]: level })) setPermitted((prev) =>
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId]
)
} }
const save = async () => { const save = async () => {
setSaving(true) setSaving(true)
const permissions = Object.entries(levels)
.filter(([, level]) => level !== 'none')
.map(([libraryId, accessLevel]) => ({ libraryId, accessLevel }))
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, { await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissions }), body: JSON.stringify({ libraryIds: permitted }),
}) })
setSaving(false) setSaving(false)
} }
@@ -272,40 +265,23 @@ function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Li
{libraries.length === 0 ? ( {libraries.length === 0 ? (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p> <p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-1.5">
{libraries.map((lib) => { {libraries.map((lib) => (
const current = levels[lib.id] ?? 'none' <label key={lib.id} className="flex items-center gap-2 cursor-pointer">
return ( <input
<div key={lib.id} className="flex items-center justify-between gap-3"> type="checkbox"
<div className="flex items-center gap-1.5 min-w-0"> checked={permitted.includes(lib.id)}
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}> onChange={() => toggle(lib.id)}
{lib.name} className="rounded"
</span> />
<span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm" style={{ color: 'var(--text-primary)' }}>
({lib.type}) {lib.name}
</span> </span>
</div> <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
<div ({lib.type})
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium" </span>
style={{ border: '1px solid var(--border)' }} </label>
> ))}
{(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => (
<button
key={lvl}
onClick={() => setLevel(lib.id, lvl)}
className="px-2.5 py-1 transition-colors capitalize"
style={{
backgroundColor: current === lvl ? 'var(--accent)' : 'transparent',
color: current === lvl ? 'var(--background)' : 'var(--text-secondary)',
}}
>
{lvl}
</button>
))}
</div>
</div>
)
})}
</div> </div>
)} )}
<button <button

View File

@@ -48,10 +48,8 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const [showOriginal, setShowOriginal] = useState(false) const [showOriginal, setShowOriginal] = useState(false)
const [extracting, setExtracting] = useState(false) const [extracting, setExtracting] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null) const [extractError, setExtractError] = useState<string | null>(null)
const [extractPending, setExtractPending] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cooldownRef = useRef(false) const cooldownRef = useRef(false)
const touchStartY = useRef<number | null>(null) const touchStartY = useRef<number | null>(null)
@@ -128,19 +126,14 @@ 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 // Fetch extracted text for current item
useEffect(() => { useEffect(() => {
if (extractPollRef.current) {
clearInterval(extractPollRef.current)
extractPollRef.current = null
}
setExtractedText(null) setExtractedText(null)
setTranslatedText(null) setTranslatedText(null)
setShowTextOverlay(false) setShowTextOverlay(false)
setShowOriginal(false) setShowOriginal(false)
setExtracting(false) setExtracting(false)
setExtractError(null) setExtractError(null)
setExtractPending(false)
if (!current?.itemKey) return if (!current?.itemKey) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`) fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -151,13 +144,6 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
.catch(() => {}) .catch(() => {})
}, [current?.itemKey]) }, [current?.itemKey])
// Clean up poll on unmount
useEffect(() => {
return () => {
if (extractPollRef.current) clearInterval(extractPollRef.current)
}
}, [])
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return } if (e.key === 'Escape') { onClose(); return }
@@ -198,44 +184,23 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const handleExtractText = async () => { const handleExtractText = async () => {
if (!current?.itemKey) return if (!current?.itemKey) return
const itemKey = current.itemKey
setExtracting(true) setExtracting(true)
setExtractError(null) setExtractError(null)
setExtractPending(false)
try { try {
const res = await fetch('/api/ai-tagging/extract-text', { const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }), body: JSON.stringify({ itemKey: current.itemKey }),
}) })
if (res.status === 202) {
// Job queued — poll until it completes (up to 5 min)
setExtractPending(true)
const deadline = Date.now() + 5 * 60 * 1000
extractPollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
if (extractPollRef.current) clearInterval(extractPollRef.current)
setExtractPending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
if (data.extractedText) {
if (extractPollRef.current) clearInterval(extractPollRef.current)
setExtractPending(false)
setExtractedText(data.extractedText)
setTranslatedText(data.extractedTextTranslated)
setShowTextOverlay(true)
}
} catch { /* ignore */ }
}, 2000)
return
}
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Extraction failed') throw new Error((data as { error?: string }).error ?? 'Extraction failed')
} }
if (res.status === 202) {
setExtractError('Queued — check AI Integrations for progress')
setTimeout(() => setExtractError(null), 4000)
return
}
const result = await res.json() const result = await res.json()
setExtractedText(result.extractedText || null) setExtractedText(result.extractedText || null)
setTranslatedText(result.translatedText || null) setTranslatedText(result.translatedText || null)
@@ -336,7 +301,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
{/* Text overlay */} {/* Text overlay */}
{showTextOverlay && displayText && ( {showTextOverlay && displayText && (
<div <div
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit" className="absolute bottom-16 left-4 right-4 z-20 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }} style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -406,20 +371,15 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
) : current?.itemKey && current?.mediaType === 'image' ? ( ) : current?.itemKey && current?.mediaType === 'image' ? (
<button <button
onClick={handleExtractText} onClick={handleExtractText}
disabled={extracting || extractPending} disabled={extracting}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40" className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
style={{ style={{
backgroundColor: extractPending backgroundColor: extractError ? 'rgba(127,29,29,0.8)' : 'rgba(0,0,0,0.5)',
? 'var(--accent)'
: extractError
? 'rgba(127,29,29,0.8)'
: 'rgba(0,0,0,0.5)',
color: extractError ? '#fca5a5' : '#fff', color: extractError ? '#fca5a5' : '#fff',
}} }}
aria-label={extractPending ? 'Extracting text…' : 'Extract text'} aria-label="Extract text"
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
> >
{extracting || extractPending ? ( {extracting ? (
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}></span> <span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}></span>
) : ( ) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View File

@@ -2,8 +2,7 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game, GameFile, GamePlatform } from '@/types' import type { Game, GameFile, GamePlatform } from '@/types'
import MediaTagPanel from '@/components/tags/MediaTagPanel' import TagSelector from '@/components/tags/TagSelector'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
// Import SVG icons // Import SVG icons
import WindowsIcon from '@/app/icons/windows.svg' import WindowsIcon from '@/app/icons/windows.svg'
@@ -30,15 +29,12 @@ interface Props {
game: Game game: Game
libraryId: string libraryId: string
onClose: () => void onClose: () => void
onPrev?: () => void
onNext?: () => void
onTagsChanged?: () => void onTagsChanged?: () => void
onCoverUploaded?: () => void onCoverUploaded?: () => void
onDeleted?: (gameId: string) => void onDeleted?: (gameId: string) => void
readOnly?: boolean
} }
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) { export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const screenshotInputRef = useRef<HTMLInputElement>(null) const screenshotInputRef = useRef<HTMLInputElement>(null)
@@ -50,9 +46,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
const [renameName, setRenameName] = useState('') const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null) const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false) const [renameSaving, setRenameSaving] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const [aiDescription, setAiDescription] = useState<string | null>(null)
// Screenshots state // Screenshots state
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([]) const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
@@ -61,8 +54,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null) const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
const [uploadingCount, setUploadingCount] = useState(0) const [uploadingCount, setUploadingCount] = useState(0)
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
const fetchScreenshots = useCallback(() => { const fetchScreenshots = useCallback(() => {
setScreenshotsLoading(true) setScreenshotsLoading(true)
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`) fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
@@ -74,14 +65,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
useEffect(() => { fetchScreenshots() }, [fetchScreenshots]) useEffect(() => { fetchScreenshots() }, [fetchScreenshots])
useEffect(() => {
if (!game.item_key) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(game.item_key)}`)
.then((r) => r.json())
.then((d: { aiDescription: string | null }) => setAiDescription(d.aiDescription ?? null))
.catch(() => {})
}, [game.item_key])
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []) const files = Array.from(e.target.files ?? [])
if (files.length === 0) return if (files.length === 0) return
@@ -123,14 +106,11 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return } if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
return return
} }
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return } if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return } if (confirming) { setConfirming(false); return }
if (renaming) { setRenaming(false); return } if (renaming) { setRenaming(false); return }
if (editingImages) { setEditingImages(false); return } if (editingImages) { setEditingImages(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose() onClose()
} }
} }
@@ -140,7 +120,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length]) }, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -173,372 +153,306 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 overflow-hidden" className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Outer flex — row on md+, col on mobile when panel open */} <div
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}> className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
{/* ── Left pane — relative container for floating controls ── */} >
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}> {editingImages ? (
{/* Scrollable card area */} <ImageEditor
<div className="h-full overflow-y-auto flex items-center justify-center p-4"> game={game}
<div libraryId={libraryId}
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl" onBack={() => setEditingImages(false)}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onUploaded={onCoverUploaded}
onClick={(e) => e.stopPropagation()} />
> ) : (
{editingImages ? ( <>
<ImageEditor {/* Close button */}
game={game}
libraryId={libraryId}
onBack={() => setEditingImages(false)}
onUploaded={onCoverUploaded}
/>
) : (
<>
{/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{heroImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
) : (
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
)}
</div>
{/* Info */}
<div className="p-5">
{/* Title row with kebab menu */}
<div className="flex items-center gap-2 mb-2">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{game.title}
</h2>
{/* Kebab menu */}
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => setMenuOpen((o) => !o)}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit images
</button>
<button
onClick={() => {
setMenuOpen(false)
setRenameName(decodeURIComponent(game.id))
setRenameError(null)
setRenaming(true)
}}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
{onDeleted && (
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete game
</button>
)}
</div>
)}
</div>}
</div>
{/* AI description (read-only) */}
{aiDescription && (
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
{aiDescription}
</p>
)}
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-4">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.() // triggers refetch
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
if (e.key === 'Escape') setRenaming(false)
}}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{/* Delete confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this game and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={() => {
setDeleting(true)
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
.then(() => onDeleted!(game.id))
.catch(() => setDeleting(false))
}}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Assigned tags (read-only) above download */}
{game.item_key && (
<div className="mb-3">
<AssignedTagBadges itemKey={game.item_key} refreshKey={tagRefreshKey} />
</div>
)}
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
{/* Screenshots */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Screenshots
</p>
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{screenshotsLoading && screenshots.length === 0 ? (
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
) : (
<>
{screenshots.map((shot, idx) => (
<div
key={shot.filename}
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
style={{ backgroundColor: 'var(--border)' }}
onClick={() => setLightboxIndex(idx)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
{deletingScreenshot !== shot.filename && (
<button
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
aria-label="Delete screenshot"
>
</button>
)}
{deletingScreenshot === shot.filename && (
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<span className="text-xs text-white">Deleting</span>
</div>
)}
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading</span>
</div>
))}
<button
onClick={() => screenshotInputRef.current?.click()}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
aria-label="Add screenshot"
>
<span className="text-xl">+</span>
</button>
</>
)}
</div>
<input
ref={screenshotInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleScreenshotUpload}
/>
</div>
</div>
</>
)}
</div>
</div>
{/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{game.item_key && !showTagPanel && (
<button
onClick={() => setShowTagPanel(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
<button <button
onClick={onClose} onClick={onClose}
className={smallBtn} className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }} style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Close" aria-label="Close"
> >
</button> </button>
</div>
{/* Prev / Next */} {/* Hero image */}
{onPrev && ( <div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
<button {heroImage ? (
onClick={(e) => { e.stopPropagation(); onPrev() }} // eslint-disable-next-line @next/next/no-img-element
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70" <img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} ) : (
aria-label="Previous" <div className="h-40 flex items-center justify-center text-5xl">🎮</div>
> )}
</div>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* Info */}
{showTagPanel && ( <div className="p-5">
<MediaTagPanel {/* Title row with kebab menu */}
itemKey={game.item_key!} <div className="flex items-center gap-2 mb-4">
onHide={() => setShowTagPanel(false)} <h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
onClose={onClose} {game.title}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} </h2>
readOnly={readOnly}
/> {/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => setMenuOpen((o) => !o)}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit images
</button>
<button
onClick={() => {
setMenuOpen(false)
setRenameName(decodeURIComponent(game.id))
setRenameError(null)
setRenaming(true)
}}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
{onDeleted && (
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete game
</button>
)}
</div>
)}
</div>
</div>
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-4">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.() // triggers refetch
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
if (e.key === 'Escape') setRenaming(false)
}}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{/* Delete confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this game and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={() => {
setDeleting(true)
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
.then(() => onDeleted!(game.id))
.catch(() => setDeleting(false))
}}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
{/* Screenshots */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Screenshots
</p>
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{screenshotsLoading && screenshots.length === 0 ? (
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
) : (
<>
{screenshots.map((shot, idx) => (
<div
key={shot.filename}
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
style={{ backgroundColor: 'var(--border)' }}
onClick={() => setLightboxIndex(idx)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
{deletingScreenshot !== shot.filename && (
<button
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
aria-label="Delete screenshot"
>
</button>
)}
{deletingScreenshot === shot.filename && (
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<span className="text-xs text-white">Deleting</span>
</div>
)}
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading</span>
</div>
))}
<button
onClick={() => screenshotInputRef.current?.click()}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
aria-label="Add screenshot"
>
<span className="text-xl">+</span>
</button>
</>
)}
</div>
<input
ref={screenshotInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleScreenshotUpload}
/>
</div>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={game.item_key!} onTagsChanged={onTagsChanged} />
</div>
</div>
</>
)} )}
</div> </div>
{/* Screenshot lightbox (z-60, sits above the modal) */} {/* Lightbox */}
{lightboxIndex !== null && ( {lightboxIndex !== null && (
<div <div
className="fixed inset-0 flex items-center justify-center" className="fixed inset-0 flex items-center justify-center"

View File

@@ -58,10 +58,9 @@ function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
interface Props { interface Props {
libraryId: string libraryId: string
readOnly?: boolean
} }
export default function GamesView({ libraryId, readOnly }: Props) { export default function GamesView({ libraryId }: Props) {
const [items, setItems] = useState<(Game | GameSeries)[]>([]) const [items, setItems] = useState<(Game | GameSeries)[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -73,10 +72,7 @@ export default function GamesView({ libraryId, readOnly }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState( const [showFilters, setShowFilters] = useState(true)
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedGameIndex, setSelectedGameIndex] = useState<number | null>(null)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -151,9 +147,6 @@ export default function GamesView({ libraryId, readOnly }: 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 (
<> <>
@@ -227,7 +220,7 @@ export default function GamesView({ libraryId, readOnly }: Props) {
<GameCard <GameCard
key={item.id} key={item.id}
game={item} game={item}
onClick={() => { setSelected(item); setSelectedGameIndex(filteredGames.indexOf(item)) }} onClick={() => setSelected(item)}
/> />
) )
)} )}
@@ -238,19 +231,11 @@ export default function GamesView({ libraryId, readOnly }: Props) {
<GameDetailModal <GameDetailModal
game={selected} game={selected}
libraryId={libraryId} libraryId={libraryId}
readOnly={readOnly} onClose={() => setSelected(null)}
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
: undefined}
onNext={selectedGameIndex !== null && selectedGameIndex < filteredGames.length - 1
? () => { const g = filteredGames[selectedGameIndex + 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex + 1) }
: undefined}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onCoverUploaded={() => fetchGames(true)} onCoverUploaded={() => fetchGames(true)}
onDeleted={() => { onDeleted={() => {
setSelected(null) setSelected(null)
setSelectedGameIndex(null)
fetchGames() fetchGames()
fetchAssignments() fetchAssignments()
}} }}
@@ -304,7 +289,6 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
const seriesPlatforms: GamePlatform[] = [ const seriesPlatforms: GamePlatform[] = [
...new Set(series.games.flatMap((g) => g.platforms)), ...new Set(series.games.flatMap((g) => g.platforms)),
] ]
const resolvedCover = series.coverUrl ?? series.games[0]?.coverUrl ?? null
return ( return (
<button <button
@@ -321,9 +305,9 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
}} }}
> >
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}> <div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{resolvedCover ? ( {series.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={resolvedCover} alt={series.title} className="absolute inset-0 w-full h-full object-cover" /> <img src={series.coverUrl} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div> <div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)} )}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState } from 'react'
import MediaTagPanel from '@/components/tags/MediaTagPanel' import TagSelector from '@/components/tags/TagSelector'
interface Props { interface Props {
url: string url: string
@@ -12,48 +12,29 @@ interface Props {
itemKey?: string itemKey?: string
onTagsChanged?: () => void onTagsChanged?: () => void
onAiTag?: () => Promise<void> onAiTag?: () => Promise<void>
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
} }
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) { export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTagsLocal, setShowTagsLocal] = useState(false) const [showTags, setShowTags] = useState(false)
const showTags = showTagsProp ?? showTagsLocal const [aiTagging, setAiTagging] = useState(false)
const setShowTags = onShowTagsChange ?? setShowTagsLocal const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
// Text extraction state // Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null) const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null) const [translatedText, setTranslatedText] = useState<string | null>(null)
const [extracting, setExtracting] = useState(false) const [extracting, setExtracting] = useState(false)
const [extractPending, setExtractPending] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null) const [extractError, setExtractError] = useState<string | null>(null)
const [retranslating, setRetranslating] = useState(false) const [retranslating, setRetranslating] = useState(false)
const [translatePending, setTranslatePending] = useState(false)
const [editedExtractedText, setEditedExtractedText] = useState<string>('') const [editedExtractedText, setEditedExtractedText] = useState<string>('')
const [savingText, setSavingText] = useState(false) const [savingText, setSavingText] = useState(false)
const [sourceLanguage, setSourceLanguage] = useState('') const [sourceLanguage, setSourceLanguage] = useState('')
// Description state
const [aiDescription, setAiDescription] = useState<string | null>(null)
const [editedDescription, setEditedDescription] = useState<string>('')
const [savingDesc, setSavingDesc] = useState(false)
const [generatingDesc, setGeneratingDesc] = useState(false)
const [descPending, setDescPending] = useState(false)
const [descError, setDescError] = useState<string | null>(null)
// OCR settings
const [ocrMode, setOcrMode] = useState<string | null>(null)
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
// Text overlay state // Text overlay state
const [showTextOverlay, setShowTextOverlay] = useState(false) const [showTextOverlay, setShowTextOverlay] = useState(false)
const [showOriginal, setShowOriginal] = useState(false) const [showOriginal, setShowOriginal] = useState(false)
// Polling ref
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Determine if this is an image file (for text extraction controls) // Determine if this is an image file (for text extraction controls)
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name) const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
@@ -61,70 +42,18 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
// Fetch existing AI fields on mount / item change // Fetch existing AI fields on mount / item change
const fetchAiFields = useCallback(() => { useEffect(() => {
if (!itemKey) return Promise.resolve() if (!itemKey) return
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => { .then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
setExtractedText(data.extractedText) setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '') setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated) setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
}) })
.catch(() => {}) .catch(() => {})
}, [itemKey]) }, [itemKey])
useEffect(() => {
fetchAiFields()
fetch('/api/ai-settings/ocr')
.then((r) => r.json())
.then((d: { ocrMode: string; ocrLanguages: string }) => {
setOcrMode(d.ocrMode)
setDefaultOcrLanguages(d.ocrLanguages)
})
.catch(() => {})
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [fetchAiFields])
// Start polling fields every 2s until data changes or 5-min timeout
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null, snapshotDesc: string | null) => {
if (!itemKey) return
if (pollRef.current) clearInterval(pollRef.current)
const deadline = Date.now() + 5 * 60 * 1000
pollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null } = await r.json()
const textChanged = data.extractedText !== snapshotText
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
const descChanged = data.aiDescription !== snapshotDesc
if (textChanged || translationChanged || descChanged) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
}
} catch { /* ignore */ }
}, 2000)
}, [itemKey])
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
@@ -143,189 +72,24 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const handleGenerateDescription = async () => {
if (!itemKey) return
setGeneratingDesc(true)
setDescError(null)
setDescPending(false)
try {
const res = await fetch('/api/ai-tagging/describe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (res.status === 202) {
setDescPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
}
const { description } = await res.json()
setAiDescription(description)
} catch (err) {
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
setTimeout(() => setDescError(null), 4000)
} finally {
setGeneratingDesc(false)
}
}
const callExtract = async (modeOverride: string) => {
setExtracting(true)
setExtractError(null)
setExtractPending(false)
try {
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey,
ocrMode: modeOverride,
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
}),
})
if (res.status === 202) {
setExtractPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
}
const result = await res.json()
setExtractedText(result.extractedText || null)
setEditedExtractedText(result.extractedText || '')
setTranslatedText(result.translatedText || null)
} catch (err) {
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
setTimeout(() => setExtractError(null), 4000)
} finally {
setExtracting(false)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 overflow-hidden" className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Outer flex — row on md+, col on mobile when panel open */} {/* Toolbar */}
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}> <div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */} {name}
<div className="relative flex-1 min-h-0 min-w-0"> </span>
{/* eslint-disable-next-line @next/next/no-img-element */} <div className="flex items-center gap-2 flex-shrink-0">
<img {/* Text overlay button — only shown when extracted text exists */}
src={url} {extractedText && (
alt={name}
className="absolute inset-0 w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
{/* ── Floating controls ── */}
{/* Filename pill — bottom-left */}
<div
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
>
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
{name}
</span>
</div>
{/* Tags + Close — top-right */}
<div
className="absolute top-4 right-4 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
{itemKey && !showTags && (
<button
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
{!showTags && (
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
title="Close"
>
</button>
)}
</div>
{/* Text display button — bottom-right, hidden when panel open */}
{!showTags && extractedText && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
className={`absolute bottom-4 right-4 ${smallBtn}`} className="w-12 h-12 rounded-full flex items-center justify-center transition-colors"
style={{ style={{
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)', backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
color: showTextOverlay ? '#fff' : 'var(--text-primary)', color: showTextOverlay ? '#fff' : 'var(--text-primary)',
@@ -339,156 +103,208 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
aria-label={showTextOverlay ? 'Hide text' : 'Show text'} aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
title="Display text" title="Display text"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/> <line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="15" y2="12"/> <line x1="3" y1="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/> <line x1="3" y1="18" x2="18" y2="18"/>
</svg> </svg>
</button> </button>
)} )}
</div> {itemKey && (
<button
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
{showTags && ( className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
<MediaTagPanel style={{
itemKey={itemKey!} backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
onHide={() => setShowTags(false)} color: showTags ? '#fff' : 'var(--text-primary)',
onClose={onClose} fontSize: '1.5rem',
onTagsChanged={onTagsChanged} }}
onAiTag={readOnly ? undefined : onAiTag} onMouseEnter={(e) => {
readOnly={readOnly} if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags"
>
🏷
</button>
)}
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}}
disabled={aiTagging}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
fontSize: '1.5rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label="AI Tag this image"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? (
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}></span>
) : '✨'}
</button>
)}
<button
onClick={onClose}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
> >
{/* Description section */}
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}> </button>
<div className="flex items-center justify-between mb-2"> </div>
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}> </div>
Description
{showTags ? (
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
{/* Image */}
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="max-w-full max-h-full w-auto h-auto object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p> </p>
<button
onClick={handleGenerateDescription}
disabled={generatingDesc || descPending}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
color: descPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div> </div>
<textarea )}
value={editedDescription} </div>
onChange={(e) => setEditedDescription(e.target.value)} {/* Tag panel */}
placeholder="No description yet…" <div
className="text-xs rounded-lg p-2 w-full resize-y outline-none" className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
style={{ style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
backgroundColor: 'var(--background)', onClick={(e) => e.stopPropagation()}
border: '1px solid var(--border)', >
color: 'var(--text-primary)', <p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
minHeight: '3.5rem', Tags
maxHeight: '8rem', </p>
fontFamily: 'inherit', <TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
}}
/>
{editedDescription !== (aiDescription ?? '') && (
<button
onClick={async () => {
setSavingDesc(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
})
setAiDescription(editedDescription)
} finally {
setSavingDesc(false)
}
}}
disabled={savingDesc}
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingDesc ? '⟳ Saving…' : 'Save'}
</button>
)}
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
</div>
{/* Text extraction section — only for images */} {/* Text extraction section — only for images */}
{isImage && ( {isImage && (
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}> <div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<div className="flex items-center justify-between"> <p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}> Text Extraction
Text Extraction </p>
</p>
<button
onClick={() => callExtract('llm')}
disabled={extracting || extractPending}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
color: extractPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="Extract text with AI"
title="Extract with AI (skips OCR)"
>
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap"> <button
<button onClick={async () => {
onClick={() => callExtract('tesseract')} setExtracting(true)
disabled={extracting || extractPending} setExtractError(null)
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0" try {
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} const res = await fetch('/api/ai-tagging/extract-text', {
onMouseEnter={(e) => { method: 'POST',
if (!extracting && !extractPending) { headers: { 'Content-Type': 'application/json' },
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' body: JSON.stringify({ itemKey }),
;(e.currentTarget as HTMLElement).style.color = 'var(--background)' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
} }
}} if (res.status === 202) {
onMouseLeave={(e) => { setExtractError('Queued — check AI Integrations for progress')
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' setTimeout(() => setExtractError(null), 4000)
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' return
}} }
> const result = await res.json()
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'} setExtractedText(result.extractedText || null)
</button> setEditedExtractedText(result.extractedText || '')
<input setTranslatedText(result.translatedText || null)
type="text" } catch (err) {
value={ocrLanguageInput} setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
onChange={(e) => setOcrLanguageInput(e.target.value)} setTimeout(() => setExtractError(null), 4000)
placeholder={defaultOcrLanguages} } finally {
className="text-xs px-2 py-0.5 rounded-full outline-none" setExtracting(false)
style={{ }
backgroundColor: 'var(--background)', }}
border: '1px solid var(--border)', disabled={extracting}
color: 'var(--text-primary)', className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2"
width: 120, style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
}} onMouseEnter={(e) => {
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default." if (!extracting) {
/> ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
</div> ;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
</button>
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>} {extractError && (
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
)}
{extractedText && ( {extractedText && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -547,6 +363,34 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
</div> </div>
)} )}
<<<<<<< Updated upstream
<div className="flex items-center gap-1.5 flex-wrap">
<input
type="text"
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="Source lang…"
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 100,
}}
/>
<button
onClick={async () => {
setRetranslating(true)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
=======
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<input <input
type="text" type="text"
@@ -564,59 +408,116 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
<button <button
onClick={async () => { onClick={async () => {
setRetranslating(true) setRetranslating(true)
setTranslatePending(false)
try { try {
const res = await fetch('/api/ai-tagging/translate', { const res = await fetch('/api/ai-tagging/translate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }), body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
}) })
if (res.status === 202) {
setTranslatePending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to translate') throw new Error((data as { error?: string }).error ?? 'Failed to translate')
} }
const result = await res.json() if (res.status === 202) {
setTranslatedText(result.translatedText || null) setExtractError('Queued — check AI Integrations for progress')
setTimeout(() => setExtractError(null), 4000)
} else {
const result = await res.json()
setTranslatedText(result.translatedText || null)
}
} catch {
// ignore
} finally {
setRetranslating(false)
>>>>>>> Stashed changes
}
if (res.status !== 202) {
const result = await res.json()
setTranslatedText(result.translatedText || null)
}
} catch { } catch {
// ignore // ignore
} finally { } finally {
setRetranslating(false) setRetranslating(false)
} }
}} }}
disabled={retranslating || translatePending} disabled={retranslating}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50" className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
color: translatePending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!retranslating && !translatePending) { if (!retranslating) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)' ;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!translatePending) { ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}} }}
> >
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'} {retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
</MediaTagPanel> </div>
)} </div>
</div> ) : (
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="max-w-full max-h-full object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
</div>
)}
</div> </div>
) )
} }

View File

@@ -11,9 +11,7 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
libraryName: string
initialPath: string initialPath: string
readOnly?: boolean
} }
type ModalState = type ModalState =
@@ -23,21 +21,18 @@ type ModalState =
type TagPanelState = { entry: FileEntry; itemKey: string } | null type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) { export default function MixedView({ libraryId, initialPath }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath) const [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState<DirectoryListing | null>(null) const [listing, setListing] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<ModalState>(null) const [modal, setModal] = useState<ModalState>(null)
const [modalShowTags, setModalShowTags] = useState(false)
const [tagPanel, setTagPanel] = useState<TagPanelState>(null) const [tagPanel, setTagPanel] = useState<TagPanelState>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState( const [showFilters, setShowFilters] = useState(true)
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([]) const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
const [recursiveLoading, setRecursiveLoading] = useState(false) const [recursiveLoading, setRecursiveLoading] = useState(false)
const [recursiveLoaded, setRecursiveLoaded] = useState(false) const [recursiveLoaded, setRecursiveLoaded] = useState(false)
@@ -88,9 +83,6 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
setDoomScrollLoading(false) setDoomScrollLoading(false)
}, [currentPath]) }, [currentPath])
const [ocrMode, setOcrMode] = useState<string | null>(null)
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
const fetchAssignments = useCallback(() => { const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -100,16 +92,6 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
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(() => {
@@ -343,20 +325,12 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm"> <nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
<a
href="/"
className="transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
Libraries
</a>
<span style={{ color: 'var(--border)' }}>/</span>
<button <button
onClick={() => loadPath('')} onClick={() => loadPath('')}
className="transition-colors" className="transition-colors"
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }} style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
> >
{libraryName} Root
</button> </button>
{breadcrumbs.map((segment, i) => { {breadcrumbs.map((segment, i) => {
const isLast = i === breadcrumbs.length - 1 const isLast = i === breadcrumbs.length - 1
@@ -413,8 +387,6 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
entry={entry} entry={entry}
onOpen={handleEntry} onOpen={handleEntry}
onTag={handleTagEntry} onTag={handleTagEntry}
ocrMode={ocrMode}
defaultOcrLanguages={defaultOcrLanguages}
onAiTag={async (e) => { onAiTag={async (e) => {
const itemKey = itemKeyFor(e) const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
@@ -429,7 +401,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
fetchAssignments() fetchAssignments()
setFilterRefreshKey((k) => k + 1) setFilterRefreshKey((k) => k + 1)
}} }}
onExtractText={async (e, ocrLanguages) => { onExtractText={async (e) => {
if (e.type === 'directory') { if (e.type === 'directory') {
// Bulk extract for directory // Bulk extract for directory
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
@@ -448,7 +420,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
const res = await fetch('/api/ai-tagging/extract-text', { const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }), body: JSON.stringify({ itemKey }),
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
@@ -481,31 +453,6 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
} }
} }
}} }}
onTranslate={async (e) => {
if (e.type === 'directory') {
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/ai-tagging/translate-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, path: dirRel }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
} else {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
}
}}
onDelete={(e) => { onDelete={(e) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' }) fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
@@ -546,13 +493,10 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
name={modal.name} name={modal.name}
itemKey={modal.itemKey} itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => { setModal(null); setModalShowTags(false) }} onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined} onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags} onAiTag={modal.itemKey ? async () => {
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -573,13 +517,10 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
name={modal.name} name={modal.name}
itemKey={modal.itemKey} itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => { setModal(null); setModalShowTags(false) }} onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined} onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags} onAiTag={async () => {
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -641,7 +582,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
) )
} }
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate, ocrMode, defaultOcrLanguages }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry, ocrLanguages?: string) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void>; ocrMode?: string | null; defaultOcrLanguages?: string }) { function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void> }) {
type ImgState = 'loading' | 'loaded' | 'error' type ImgState = 'loading' | 'loaded' | 'error'
const [imgState, setImgState] = useState<ImgState>( const [imgState, setImgState] = useState<ImgState>(
entry.thumbnailUrl ? 'loading' : 'error' entry.thumbnailUrl ? 'loading' : 'error'
@@ -660,10 +601,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
const [textExtractError, setTextExtractError] = useState<string | null>(null) const [textExtractError, setTextExtractError] = useState<string | null>(null)
const [describing, setDescribing] = useState(false) const [describing, setDescribing] = useState(false)
const [describeError, setDescribeError] = useState<string | null>(null) const [describeError, setDescribeError] = useState<string | null>(null)
const [translating, setTranslating] = useState(false)
const [translateError, setTranslateError] = useState<string | null>(null)
const [showOcrPrompt, setShowOcrPrompt] = useState(false)
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
useEffect(() => { useEffect(() => {
if (!menuOpen) return if (!menuOpen) return
@@ -778,7 +715,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
</button> </button>
{/* Kebab menu — bottom-right, shown on hover */} {/* Kebab menu — bottom-right, shown on hover */}
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory')) || (onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText)) && ( {(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && (
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}> <div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
<button <button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }} onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
@@ -853,21 +790,16 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
📝 Describe Folder 📝 Describe Folder
</button> </button>
)} )}
{onExtractText && entry.mediaType === 'image' && !showOcrPrompt && ( {onExtractText && entry.mediaType === 'image' && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (ocrMode && ocrMode !== 'llm') { setMenuOpen(false)
setOcrLanguageInput('') setTextExtracting(true)
setShowOcrPrompt(true) setTextExtractError(null)
} else { onExtractText(entry)
setMenuOpen(false) .catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
setTextExtracting(true) .finally(() => setTextExtracting(false))
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}
}} }}
disabled={textExtracting} disabled={textExtracting}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50" className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
@@ -878,57 +810,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
🔍 Extract Text 🔍 Extract Text
</button> </button>
)} )}
{onExtractText && entry.mediaType === 'image' && showOcrPrompt && (
<div className="px-4 py-2 flex flex-col gap-2" onClick={(e) => e.stopPropagation()}>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>OCR language</p>
<input
autoFocus
type="text"
value={ocrLanguageInput}
onChange={(e) => setOcrLanguageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') { setShowOcrPrompt(false) }
if (e.key === 'Enter') {
setShowOcrPrompt(false)
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry, ocrLanguageInput.trim() || undefined)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}
}}
placeholder={defaultOcrLanguages ?? 'eng'}
className="text-xs px-2 py-1 rounded-lg outline-none w-full"
style={{ backgroundColor: 'var(--background)', border: '1px solid var(--border)', color: 'var(--text-primary)' }}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/>
<div className="flex gap-2">
<button
onClick={() => {
setShowOcrPrompt(false)
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry, ocrLanguageInput.trim() || undefined)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}}
className="text-xs px-2 py-1 rounded-lg"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
Extract
</button>
<button
onClick={() => setShowOcrPrompt(false)}
className="text-xs px-2 py-1"
style={{ color: 'var(--text-secondary)' }}
>
Cancel
</button>
</div>
</div>
)}
{onExtractText && entry.type === 'directory' && ( {onExtractText && entry.type === 'directory' && (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -949,26 +830,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
🔍 Extract Text for Folder 🔍 Extract Text for Folder
</button> </button>
)} )}
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTranslating(true)
setTranslateError(null)
onTranslate(entry)
.catch((err) => setTranslateError(err instanceof Error ? err.message : 'Translation failed'))
.finally(() => setTranslating(false))
}}
disabled={translating}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
</button>
)}
{onRename && ( {onRename && (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -1068,28 +929,6 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
</div> </div>
)} )}
{/* Translation status overlay */}
{(translating || translateError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: translateError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: translateError ? '#fca5a5' : 'var(--text-secondary)' }}>
{translateError ?? 'Translating…'}
</span>
{translateError && (
<button
onClick={() => setTranslateError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Delete confirmation overlay */} {/* Delete confirmation overlay */}
{confirming && ( {confirming && (
<div <div

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import MediaTagPanel from '@/components/tags/MediaTagPanel' import TagSelector from '@/components/tags/TagSelector'
import { useUserSettings } from '@/hooks/useUserSettings' import { useUserSettings } from '@/hooks/useUserSettings'
interface Props { interface Props {
@@ -14,21 +14,18 @@ interface Props {
onTagsChanged?: () => void onTagsChanged?: () => void
onAiTag?: () => Promise<void> onAiTag?: () => Promise<void>
context?: 'mixed' | 'movies' | 'tv' context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
} }
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) { export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed' }: Props) {
const settings = useUserSettings() const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTagsLocal, setShowTagsLocal] = useState(false) const [showTags, setShowTags] = useState(false)
const showTags = showTagsProp ?? showTagsLocal const [aiTagging, setAiTagging] = useState(false)
const setShowTags = onShowTagsChange ?? setShowTagsLocal const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -48,58 +45,93 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 overflow-hidden" className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Outer flex — row on md+, col on mobile when panel open */} {/* Toolbar */}
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}> <div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{itemKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
color: showTags ? '#fff' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags"
>
🏷
</button>
)}
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}}
disabled={aiTagging}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label="AI Tag this video"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? (
<span className="animate-spin" style={{ display: 'inline-block' }}></span>
) : '✨'}
</button>
)}
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
</div>
{/* ── Video column ── */} {showTags ? (
<div className="flex flex-col flex-1 min-h-0 min-w-0 relative"> <div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
{/* Video */}
{/* Toolbar — scoped to this column's width */} <div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
<div className="flex items-center justify-between px-3 py-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-sm truncate mr-2" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
{itemKey && !showTags && (
<button
onClick={() => setShowTags(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
{!showTags && (
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
title="Close"
>
</button>
)}
</div>
</div>
{/* Video area — single element, never remounts on panel toggle */}
<div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
<video <video
key={url} key={url}
src={url} src={url}
@@ -107,18 +139,60 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
autoPlay={autoPlay} autoPlay={autoPlay}
muted={muted} muted={muted}
loop={loop} loop={loop}
playsInline className="w-full h-full object-contain rounded-lg"
className="w-full h-full object-contain"
style={{ backgroundColor: '#000' }} style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/> />
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div> </div>
{/* Tag panel */}
{/* Prev/Next — positioned relative to the full column height (incl. toolbar) <div
so they align with ImageLightbox's buttons which span the full viewport */} className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
</div>
</div>
) : (
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
<video
key={url}
src={url}
controls
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full max-w-4xl object-contain rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
{onPrev && ( {onPrev && (
<button <button
onClick={(e) => { e.stopPropagation(); onPrev() }} onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70" className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous" aria-label="Previous"
> >
@@ -128,7 +202,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
{onNext && ( {onNext && (
<button <button
onClick={(e) => { e.stopPropagation(); onNext() }} onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70" className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next" aria-label="Next"
> >
@@ -136,19 +210,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
</button> </button>
)} )}
</div> </div>
)}
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
{showTags && (
<MediaTagPanel
itemKey={itemKey!}
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
/>
)}
</div>
</div> </div>
) )
} }

View File

@@ -2,8 +2,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Movie } from '@/types' import type { Movie } from '@/types'
import MediaTagPanel from '@/components/tags/MediaTagPanel' import TagSelector from '@/components/tags/TagSelector'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
interface Props { interface Props {
@@ -15,10 +14,9 @@ interface Props {
onTagsChanged?: () => void onTagsChanged?: () => void
onDeleted: (movieId: string) => void onDeleted: (movieId: string) => void
onMetadataRefreshed?: () => void onMetadataRefreshed?: () => void
readOnly?: boolean
} }
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) { export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
@@ -34,22 +32,15 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
const [renameName, setRenameName] = useState('') const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null) const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false) const [renameSaving, setRenameSaving] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return } if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return } if (confirming) { setConfirming(false); return }
if (warnRefresh) { setWarnRefresh(false); return } if (warnRefresh) { setWarnRefresh(false); return }
if (editing) { setEditing(false); return } if (editing) { setEditing(false); return }
if (renaming) { setRenaming(false); return } if (renaming) { setRenaming(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose() onClose()
} }
} }
@@ -59,7 +50,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel]) }, [onClose, menuOpen, confirming, editing, warnRefresh, renaming])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -141,6 +132,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
const handleStartRename = () => { const handleStartRename = () => {
setMenuOpen(false) setMenuOpen(false)
// movie.id is the encoded folder name
setRenameName(decodeURIComponent(movie.id)) setRenameName(decodeURIComponent(movie.id))
setRenameError(null) setRenameError(null)
setRenaming(true) setRenaming(true)
@@ -195,387 +187,339 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 overflow-hidden" className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Outer flex — row on md+, col on mobile when panel open */} <div
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}> className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Close"
>
</button>
{/* ── Left pane — relative container for floating controls ── */} {/* Prev / Next buttons on the detail card */}
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}> {onPrev && (
{/* Scrollable card area */} <button
<div className="h-full overflow-y-auto flex items-center justify-center p-4"> onClick={onPrev}
<div className="absolute top-3 left-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl" style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onClick={(e) => e.stopPropagation()} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Previous movie"
> >
</button>
)}
{onNext && (
<button
onClick={onNext}
className="absolute top-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)', right: onPrev ? '3rem' : undefined }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Next movie"
>
</button>
)}
{/* Hero image */} {/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}> <div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{heroUrl ? ( {heroUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={heroUrl} src={heroUrl}
alt={movie.title} alt={movie.title}
className="w-full object-cover max-h-64" className="w-full object-cover max-h-64"
/> />
) : ( ) : (
<div className="h-40 flex items-center justify-center text-5xl">🎬</div> <div className="h-40 flex items-center justify-center text-5xl">🎬</div>
)}
</div>
{/* Info */}
<div className="p-5">
{/* Title row with kebab menu */}
<div className="flex items-start gap-2 mb-1">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{movie.title}
</h2>
{movie.year && (
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{movie.year}
</span>
)}
{/* Kebab menu */}
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={handleRefreshMetadata}
disabled={refreshing}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
</button>
<button
onClick={handleStartEditing}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit metadata
</button>
<button
onClick={handleStartRename}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete movie
</button>
</div>
)}
</div>}
</div>
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-3">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{editing ? (
<div className="flex flex-col gap-3 mb-4">
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
<input
type="number"
value={editForm.year}
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
<textarea
rows={3}
value={editForm.plot}
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
<input
type="text"
value={editForm.genres}
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleSaveMetadata}
disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
) : (
<>
{/* Meta row */}
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{movie.rating !== null && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{movie.rating.toFixed(1)}
</span>
)}
{movie.runtime !== null && (
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{movie.runtime} min
</span>
)}
{movie.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{g}
</span>
))}
</div>
)}
{movie.plot && (
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
{movie.plot}
</p>
)}
</>
)}
{/* NFO refresh warning */}
{warnRefresh && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
>
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
Refreshing from NFO will overwrite your manual edits.
</p>
<button
onClick={() => setWarnRefresh(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={doRefreshMetadata}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
>
Overwrite
</button>
</div>
)}
{/* Confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this movie and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={handleConfirmDelete}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Assigned tags (read-only) above action buttons */}
{movie.item_key && (
<div className="mb-3">
<AssignedTagBadges itemKey={movie.item_key} refreshKey={tagRefreshKey} />
</div>
)}
{/* Action buttons row: Play + Download */}
<div className="flex gap-2">
<button
onClick={() => setPlaying(true)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Play
</button>
<a
href={videoUrl}
download
className="flex items-center justify-center px-3 py-2.5 rounded-lg text-sm font-medium transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onClick={(e) => e.stopPropagation()}
title="Download"
aria-label="Download"
>
</a>
</div>
</div>
</div>
</div>
{/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{movie.item_key && !showTagPanel && (
<button
onClick={() => setShowTagPanel(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)} )}
</div> </div>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* Info */}
{showTagPanel && ( <div className="p-5">
<MediaTagPanel {/* Title row with kebab menu */}
itemKey={movie.item_key!} <div className="flex items-start gap-2 mb-1">
onHide={() => setShowTagPanel(false)} <h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
onClose={onClose} {movie.title}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} </h2>
readOnly={readOnly} {movie.year && (
/> <span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
)} {movie.year}
</span>
)}
{/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={handleRefreshMetadata}
disabled={refreshing}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
</button>
<button
onClick={handleStartEditing}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit metadata
</button>
<button
onClick={handleStartRename}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete movie
</button>
</div>
)}
</div>
</div>
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-3">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{editing ? (
<div className="flex flex-col gap-3 mb-4">
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
<input
type="number"
value={editForm.year}
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
<textarea
rows={3}
value={editForm.plot}
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
<input
type="text"
value={editForm.genres}
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleSaveMetadata}
disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
) : (
<>
{/* Meta row */}
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{movie.rating !== null && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{movie.rating.toFixed(1)}
</span>
)}
{movie.runtime !== null && (
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{movie.runtime} min
</span>
)}
{movie.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{g}
</span>
))}
</div>
)}
{movie.plot && (
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
{movie.plot}
</p>
)}
</>
)}
{/* NFO refresh warning */}
{warnRefresh && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
>
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
Refreshing from NFO will overwrite your manual edits.
</p>
<button
onClick={() => setWarnRefresh(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={doRefreshMetadata}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
>
Overwrite
</button>
</div>
)}
{/* Confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this movie and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={handleConfirmDelete}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Play button */}
<button
onClick={() => setPlaying(true)}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Play
</button>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={movie.item_key!} onTagsChanged={onTagsChanged} />
</div>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -9,10 +9,9 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
readOnly?: boolean
} }
export default function MoviesView({ libraryId, readOnly }: Props) { export default function MoviesView({ libraryId }: Props) {
const [movies, setMovies] = useState<Movie[]>([]) const [movies, setMovies] = useState<Movie[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -21,9 +20,7 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState( const [showFilters, setShowFilters] = useState(true)
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([]) const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
@@ -204,7 +201,6 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
<MovieDetailModal <MovieDetailModal
movie={selected} movie={selected}
libraryId={libraryId} libraryId={libraryId}
readOnly={readOnly}
onClose={() => setSelectedIndex(null)} onClose={() => setSelectedIndex(null)}
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined} onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined} onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}

View File

@@ -1,73 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import type { Tag, TagCategory } from '@/types'
interface Props {
itemKey: string
refreshKey?: number
}
export default function AssignedTagBadges({ itemKey, refreshKey }: Props) {
const [tags, setTags] = useState<Tag[]>([])
const [categories, setCategories] = useState<TagCategory[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch(`/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json())
.then((data: { tags: Tag[]; categories: TagCategory[] }) => {
setTags(data.tags ?? [])
setCategories(data.categories ?? [])
})
.catch(() => {})
.finally(() => setLoading(false))
}, [itemKey, refreshKey])
if (loading) {
return (
<div className="flex flex-wrap gap-1.5">
{[60, 80, 50].map((w) => (
<div
key={w}
className="h-5 rounded-full animate-pulse"
style={{ width: w, backgroundColor: 'var(--border)' }}
/>
))}
</div>
)
}
if (tags.length === 0) return null
const catMap = new Map(categories.map((c) => [c.id, c.name]))
// Group by category
const grouped = new Map<string | null, Tag[]>()
for (const tag of tags) {
const key = tag.categoryId ?? null
if (!grouped.has(key)) grouped.set(key, [])
grouped.get(key)!.push(tag)
}
return (
<div className="flex flex-wrap gap-1.5">
{Array.from(grouped.entries()).map(([catId, catTags]) => {
const catName = catId ? catMap.get(catId) : null
return catTags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{catName && (
<span style={{ color: 'var(--text-secondary)' }}>{catName}:</span>
)}
{tag.name}
</span>
))
})}
</div>
)
}

View File

@@ -1,138 +0,0 @@
'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>
)
}

View File

@@ -8,8 +8,6 @@ interface Props {
itemKey: string itemKey: string
onTagsChanged?: () => void onTagsChanged?: () => void
refreshKey?: number refreshKey?: number
hideDescription?: boolean
readOnly?: boolean
} }
interface AllTags { interface AllTags {
@@ -17,7 +15,7 @@ interface AllTags {
tags: Tag[] tags: Tag[]
} }
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) { export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({ const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [], tags: [],
categories: [], categories: [],
@@ -212,39 +210,37 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* AI description */} {/* AI description */}
{!hideDescription && ( <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> {aiDescription && (
{aiDescription && ( <p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}> {aiDescription}
{aiDescription} </p>
</p> )}
<div className="flex items-center gap-1.5">
<button
onClick={handleGenerateDescription}
disabled={generatingDesc}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!generatingDesc) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)} )}
<div className="flex items-center gap-1.5">
<button
onClick={handleGenerateDescription}
disabled={generatingDesc}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!generatingDesc) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)}
</div>
</div> </div>
)} </div>
{/* Assigned tags grouped by category */} {/* Assigned tags grouped by category */}
{assigned.tags.length > 0 && ( {assigned.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -278,25 +274,23 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
style={{ backgroundColor: 'var(--surface-hover)' }} style={{ backgroundColor: 'var(--surface-hover)' }}
> >
{tag.name} {tag.name}
{!readOnly && ( <button
<button onClick={() => toggleTag(tag)}
onClick={() => toggleTag(tag)} className="ml-0.5 leading-none transition-colors"
className="ml-0.5 leading-none transition-colors" style={{ color: 'var(--text-secondary)' }}
style={{ color: 'var(--text-secondary)' }} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')} aria-label={`Remove tag ${tag.name}`}
aria-label={`Remove tag ${tag.name}`} >
>
</button>
</button>
)}
</span> </span>
))} ))}
</span> </span>
) )
})} })}
{ungrouped.map((tag) => ( {ungrouped.map((tag) => (
<TagBadge key={tag.id} tag={tag} onRemove={readOnly ? undefined : () => toggleTag(tag)} /> <TagBadge key={tag.id} tag={tag} onRemove={() => toggleTag(tag)} />
))} ))}
</> </>
) )
@@ -305,7 +299,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
)} )}
{/* Tag picker grouped by category */} {/* Tag picker grouped by category */}
{!readOnly && <div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{all.categories.map((category) => { {all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id) const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? '' const search = categorySearches[category.id] ?? ''
@@ -534,7 +528,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
</button> </button>
)} )}
</div> </div>
</div>} </div>
</div> </div>
) )
} }

View File

@@ -9,10 +9,9 @@ interface Props {
onTag?: () => void onTag?: () => void
onDelete?: () => void onDelete?: () => void
onRename?: (newName: string) => Promise<boolean> onRename?: (newName: string) => Promise<boolean>
downloadUrl?: string
} }
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename, downloadUrl }: Props) { export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename }: Props) {
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
@@ -80,7 +79,7 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
</button> </button>
)} )}
{/* Kebab menu */} {/* Kebab menu */}
{(onDelete || downloadUrl) && ( {onDelete && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}> <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
<button <button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }} onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
@@ -95,19 +94,6 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max" className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
> >
{downloadUrl && (
<a
href={downloadUrl}
download
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Download
</a>
)}
{onRename && ( {onRename && (
<button <button
onClick={(e) => { onClick={(e) => {

View File

@@ -5,21 +5,18 @@ 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, readOnly }: Props) { export default function TvView({ libraryId }: Props) {
const [view, setView] = useState<ViewLevel>('series') const [view, setView] = useState<ViewLevel>('series')
const [series, setSeries] = useState<TvSeries[]>([]) const [series, setSeries] = useState<TvSeries[]>([])
const [seasons, setSeasons] = useState<TvSeason[]>([]) const [seasons, setSeasons] = useState<TvSeason[]>([])
@@ -34,11 +31,7 @@ export default function TvView({ libraryId, readOnly }: 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( const [showFilters, setShowFilters] = useState(true)
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedSeriesIndex, setSelectedSeriesIndex] = useState<number | null>(null)
const [selectedSeasonIndex, setSelectedSeasonIndex] = useState<number | null>(null)
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null) const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
@@ -55,12 +48,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([]) const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [doomScrollLoading, setDoomScrollLoading] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagPanelItemKey, setTagPanelItemKey] = useState<string | null>(null)
const [tagPanelDisabled, setTagPanelDisabled] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -99,7 +87,6 @@ export default function TvView({ libraryId, readOnly }: Props) {
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags]) useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
const openSeries = (s: TvSeries) => { const openSeries = (s: TvSeries) => {
setSelectedSeriesIndex(filteredSeries.indexOf(s))
setSelectedSeries(s) setSelectedSeries(s)
setView('seasons') setView('seasons')
setLoading(true) setLoading(true)
@@ -109,17 +96,18 @@ export default function TvView({ libraryId, readOnly }: Props) {
.then((data: TvSeason[]) => { .then((data: TvSeason[]) => {
setSeasons(data) setSeasons(data)
setLoading(false) setLoading(false)
// Flat series: a single synthetic season (id='.') means episodes live
// directly in the series folder — skip the seasons screen automatically.
if (data.length === 1 && data[0].id === '.') {
openSeason(data[0])
}
}) })
.catch(() => { setError('Failed to load seasons'); setLoading(false) }) .catch(() => { setError('Failed to load seasons'); setLoading(false) })
} }
const openSeason = (season: TvSeason, index?: number) => { const openSeason = (season: TvSeason) => {
setSelectedSeasonIndex(index ?? seasons.indexOf(season))
setSelectedSeason(season) setSelectedSeason(season)
setView('episodes') setView('episodes')
if (showTagPanel) {
setTagPanelDisabled(true)
}
setLoading(true) setLoading(true)
setError(null) setError(null)
fetch( fetch(
@@ -146,24 +134,14 @@ export default function TvView({ libraryId, readOnly }: Props) {
setView('series') setView('series')
setSelectedSeries(null) setSelectedSeries(null)
setSelectedSeason(null) setSelectedSeason(null)
setSelectedSeriesIndex(null)
setSelectedSeasonIndex(null)
setMenuOpen(false) setMenuOpen(false)
setConfirming(false) setConfirming(false)
setShowTagPanel(false)
setTagPanelItemKey(null)
setTagPanelDisabled(false)
} }
const goToSeasons = () => { const goToSeasons = () => {
setView('seasons') setView('seasons')
setSelectedSeason(null) setSelectedSeason(null)
setSelectedSeasonIndex(null)
setConfirming(false) setConfirming(false)
if (showTagPanel && selectedSeries?.item_key) {
setTagPanelItemKey(selectedSeries.item_key)
setTagPanelDisabled(false)
}
} }
const handleDeleteSeries = () => { const handleDeleteSeries = () => {
@@ -186,18 +164,11 @@ export default function TvView({ libraryId, readOnly }: Props) {
setRefreshingMeta(true) setRefreshingMeta(true)
setWarnRefresh(false) setWarnRefresh(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}` const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
const currentId = selectedSeries.id
fetch( fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`, `/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
{ method: 'POST' } { method: 'POST' }
) )
.then(() => fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`)) .then(() => fetchSeries())
.then((r) => r.json())
.then((data: TvSeries[]) => {
setSeries(data)
const updated = data.find((s) => s.id === currentId)
if (updated) setSelectedSeries(updated)
})
.finally(() => setRefreshingMeta(false)) .finally(() => setRefreshingMeta(false))
} }
@@ -341,40 +312,6 @@ export default function TvView({ libraryId, readOnly }: 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) => {
@@ -399,28 +336,6 @@ export default function TvView({ libraryId, readOnly }: 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) {
@@ -435,7 +350,6 @@ export default function TvView({ libraryId, readOnly }: Props) {
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined} onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined} onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
context="tv" context="tv"
readOnly={readOnly}
/> />
) )
} }
@@ -588,76 +502,9 @@ export default function TvView({ libraryId, readOnly }: Props) {
)} )}
</div> </div>
</div> </div>
{tagPanel && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.title}
</p>
</div>
<button
onClick={() => setTagPanel(null)}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
aria-label="Close"
>
</button>
</div>
<div className="px-5 py-4">
<TagSelector
itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
/>
</div>
</div>
</div>
)}
</> </>
)} )}
{(view === 'seasons' || view === 'episodes') && (
<div
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
>
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
<div className="flex-1 min-h-0 min-w-0 relative" onClick={goToSeries}>
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{view === 'episodes' && (
<div className="flex items-center gap-2 px-5 py-3 flex-shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
<button
onClick={(e) => { e.stopPropagation(); goToSeasons() }}
className="text-sm transition-colors hover:underline"
style={{ color: 'var(--accent)' }}
>
{selectedSeries?.title}
</button>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>·</span>
<span className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{selectedSeason?.title}
</span>
</div>
)}
{view === 'seasons' && selectedSeries && ( {view === 'seasons' && selectedSeries && (
<div> <div>
{/* Series info header */} {/* Series info header */}
@@ -835,11 +682,6 @@ export default function TvView({ libraryId, readOnly }: Props) {
{selectedSeries.plot && ( {selectedSeries.plot && (
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p> <p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
)} )}
{selectedSeries.item_key && (
<div className="mt-2">
<AssignedTagBadges itemKey={selectedSeries.item_key} refreshKey={tagRefreshKey} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -914,7 +756,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
{seasons.map((season) => ( {seasons.map((season) => (
<button <button
key={season.id} key={season.id}
onClick={() => openSeason(season, seasons.indexOf(season))} onClick={() => openSeason(season)}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2" className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
@@ -950,7 +792,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
)} )}
{view === 'episodes' && selectedSeason && ( {view === 'episodes' && selectedSeason && (
<div className="p-4"> <div>
{loading ? ( {loading ? (
<EpisodeLoadingGrid /> <EpisodeLoadingGrid />
) : error ? ( ) : error ? (
@@ -966,8 +808,7 @@ export default function TvView({ libraryId, readOnly }: Props) {
key={ep.id} key={ep.id}
episode={ep} episode={ep}
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))} onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => { setTagPanelItemKey(ep.item_key!); setTagPanelDisabled(false); setShowTagPanel(true) }} onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
downloadUrl={`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`}
onDelete={() => { onDelete={() => {
fetch( fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`, `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
@@ -997,91 +838,42 @@ export default function TvView({ libraryId, readOnly }: Props) {
)} )}
</div> </div>
)} )}
</div> {tagPanel && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.title}
</p>
</div> </div>
<button
{/* Floating controls — tag + close */} onClick={() => setTagPanel(null)}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}> className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && ( style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
<button onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
className={smallBtn} aria-label="Close"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }} >
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} </button>
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
<button
onClick={goToSeries}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
{/* Prev — series in seasons view, season in episodes view */}
{(view === 'seasons'
? selectedSeriesIndex !== null && selectedSeriesIndex > 0
: selectedSeasonIndex !== null && selectedSeasonIndex > 0) && (
<button
onClick={(e) => {
e.stopPropagation()
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! - 1])
else openSeason(seasons[selectedSeasonIndex! - 1], selectedSeasonIndex! - 1)
}}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{/* Next — series in seasons view, season in episodes view */}
{(view === 'seasons'
? selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1
: selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1) && (
<button
onClick={(e) => {
e.stopPropagation()
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! + 1])
else openSeason(seasons[selectedSeasonIndex! + 1], selectedSeasonIndex! + 1)
}}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div> </div>
<div className="px-5 py-4">
{/* Right tag panel */} <TagSelector
{showTagPanel && ( itemKey={tagPanel.itemKey}
<MediaTagPanel onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
itemKey={tagPanelItemKey ?? ''}
onHide={() => setShowTagPanel(false)}
onClose={goToSeries}
onTagsChanged={() => {
setTagRefreshKey((k) => k + 1)
setFilterRefreshKey((k) => k + 1)
fetchAssignments()
fetchSeriesEpisodeTags()
}}
externalRefreshKey={tagRefreshKey}
disabled={tagPanelDisabled}
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
readOnly={readOnly}
/> />
)} </div>
</div> </div>
</div> </div>
)} )}

View File

@@ -34,7 +34,6 @@ interface AiJobRow {
started_at: number | null started_at: number | null
completed_at: number | null completed_at: number | null
item_title: string | null item_title: string | null
payload: string | null
} }
function rowToJob(row: AiJobRow): AiJob { function rowToJob(row: AiJobRow): AiJob {
@@ -76,7 +75,6 @@ export function enqueueJob(
jobType: AiJobType, jobType: AiJobType,
libraryId: string, libraryId: string,
sourceLanguage?: string, sourceLanguage?: string,
payload?: Record<string, string>,
): string { ): string {
const db = getDb() const db = getDb()
@@ -98,9 +96,9 @@ export function enqueueJob(
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
db.prepare( db.prepare(
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title, payload) `INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null) ).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
// Wake the processor // Wake the processor
wakeProcessor() wakeProcessor()
@@ -253,14 +251,13 @@ async function processNextJob(): Promise<boolean> {
// Extract sourceLanguage for translate jobs (stored in error field at enqueue) // Extract sourceLanguage for translate jobs (stored in error field at enqueue)
const sourceLanguage = row.job_type === 'translate' ? row.error : null const sourceLanguage = row.job_type === 'translate' ? row.error : null
// Parse job payload (carries per-call overrides, e.g. ocrLanguages for extract jobs)
const jobPayload = row.payload ? (JSON.parse(row.payload) as Record<string, string>) : null
db.prepare( db.prepare(
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?" "UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
).run(now, row.id) ).run(now, row.id)
try { try {
console.log(`[ai-jobs] Processing job ${row.id}: ${row.job_type} for "${row.item_key}"`)
switch (row.job_type) { switch (row.job_type) {
case 'tag': case 'tag':
await tagSingleItem(row.item_key) await tagSingleItem(row.item_key)
@@ -269,7 +266,358 @@ async function processNextJob(): Promise<boolean> {
await generateItemDescription(row.item_key) await generateItemDescription(row.item_key)
break break
case 'extract': case 'extract':
await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode) await extractItemText(row.item_key)
break
case 'translate':
await translateItemText(row.item_key, sourceLanguage || undefined)
break
}
db.prepare(
"UPDATE ai_jobs SET status = 'completed', completed_at = ? WHERE id = ?"
).run(Date.now(), row.id)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
const attempt = row.attempt + 1
if (attempt < row.max_retries) {
// Re-queue for retry
db.prepare(
"UPDATE ai_jobs SET status = 'queued', attempt = ?, error = ?, started_at = NULL WHERE id = ?"
).run(attempt, errorMessage, row.id)
} else {
// Final failure
db.prepare(
"UPDATE ai_jobs SET status = 'failed', attempt = ?, error = ?, completed_at = ? WHERE id = ?"
).run(attempt, errorMessage, Date.now(), row.id)
}
console.warn(
`[ai-jobs] Job ${row.id} (${row.job_type} for "${row.item_key}") failed (attempt ${attempt}/${row.max_retries}):`,
errorMessage
)
}
return true
}
async function runProcessor(): Promise<void> {
if (processorRunning) return
processorRunning = true
console.log('[ai-jobs] Processor started')
try {
while (true) {
const hadWork = await processNextJob()
if (!hadWork) {
// Wait for a wake signal or timeout after 60s (then check again for safety)
await new Promise<void>((resolve) => {
processorWake = resolve
setTimeout(() => {
processorWake = null
resolve()
}, 60_000)
})
processorWake = null
}
}
} catch (err) {
console.error('[ai-jobs] Processor crashed:', err)
} finally {
processorRunning = false
console.log('[ai-jobs] Processor stopped')
}
}
/**
* Initialize the job processor. Called on server startup.
* Resets any jobs stuck in 'running' state (from a previous crash) back to 'queued'.
*/
export function initJobProcessor(): void {
const db = getDb()
const result = db
.prepare("UPDATE ai_jobs SET status = 'queued', started_at = NULL WHERE status = 'running'")
.run()
if (result.changes > 0) {
console.log(`[ai-jobs] Reset ${result.changes} stuck running job(s) to queued`)
}
// Check if there are any queued jobs and start the processor
const pending = db
.prepare("SELECT COUNT(*) as count FROM ai_jobs WHERE status = 'queued'")
.get() as { count: number }
if (pending.count > 0) {
runProcessor()
}
}
import crypto from 'crypto'
import { getDb } from './db'
import { getAiMaxRetries } from './app-settings'
import { tagSingleItem, generateItemDescription, extractItemText, translateItemText } from './ai-tagger'
export type AiJobType = 'tag' | 'describe' | 'extract' | 'translate'
export type AiJobStatus = 'queued' | 'running' | 'completed' | 'failed'
export interface AiJob {
id: string
itemKey: string
libraryId: string
jobType: AiJobType
status: AiJobStatus
error: string | null
attempt: number
maxRetries: number
createdAt: number
startedAt: number | null
completedAt: number | null
itemTitle: string | null
}
interface AiJobRow {
id: string
item_key: string
library_id: string
job_type: string
status: string
error: string | null
attempt: number
max_retries: number
created_at: number
started_at: number | null
completed_at: number | null
item_title: string | null
}
function rowToJob(row: AiJobRow): AiJob {
return {
id: row.id,
itemKey: row.item_key,
libraryId: row.library_id,
jobType: row.job_type as AiJobType,
status: row.status as AiJobStatus,
error: row.error,
attempt: row.attempt,
maxRetries: row.max_retries,
createdAt: row.created_at,
startedAt: row.started_at,
completedAt: row.completed_at,
itemTitle: row.item_title,
}
}
/**
* Look up the title of a media item for display purposes.
*/
function resolveItemTitle(itemKey: string): string | null {
const db = getDb()
const row = db
.prepare('SELECT title FROM media_items WHERE item_key = ?')
.get(itemKey) as { title: string | null } | undefined
return row?.title ?? null
}
// ─── Enqueue ─────────────────────────────────────────────────────────────────
/**
* Enqueue an AI job. Deduplicates: if a queued/running job with the same
* item_key + job_type already exists, returns its ID instead.
*/
export function enqueueJob(
itemKey: string,
jobType: AiJobType,
libraryId: string,
sourceLanguage?: string,
): string {
const db = getDb()
// Deduplication: check for existing queued/running job
const existing = db
.prepare(
`SELECT id FROM ai_jobs
WHERE item_key = ? AND job_type = ? AND status IN ('queued', 'running')`
)
.get(itemKey, jobType) as { id: string } | undefined
if (existing) return existing.id
const id = crypto.randomUUID()
const maxRetries = getAiMaxRetries()
const title = resolveItemTitle(itemKey)
// Store sourceLanguage in the error field temporarily for translate jobs
// (it's null at creation, so we repurpose it briefly — cleared when the job runs)
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
db.prepare(
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
// Wake the processor
wakeProcessor()
return id
}
/**
* Enqueue jobs for all media items in a directory (for bulk operations).
* Returns the list of job IDs created.
*/
export function enqueueBulkJobs(
libraryId: string,
dirPath: string,
jobType: AiJobType,
itemTypeFilter?: string,
extFilter?: Set<string>,
): string[] {
const db = getDb()
const prefix = dirPath
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
: `${libraryId}:mixed_file:`
const items = db
.prepare('SELECT item_key, item_type, file_path FROM media_items WHERE item_key LIKE ? AND item_type = ?')
.all(`${prefix}%`, itemTypeFilter ?? 'mixed_file') as Array<{ item_key: string; item_type: string; file_path: string | null }>
const path = require('path')
const jobIds: string[] = []
for (const item of items) {
if (!item.file_path) continue
if (extFilter) {
const ext = path.extname(item.file_path).toLowerCase()
if (!extFilter.has(ext)) continue
}
const jobId = enqueueJob(item.item_key, jobType, libraryId)
jobIds.push(jobId)
}
return jobIds
}
// ─── Query ───────────────────────────────────────────────────────────────────
export function getJobQueue(): AiJob[] {
const db = getDb()
const rows = db
.prepare(
`SELECT * FROM ai_jobs
WHERE status IN ('running', 'queued')
ORDER BY
CASE status WHEN 'running' THEN 0 ELSE 1 END,
created_at ASC`
)
.all() as AiJobRow[]
return rows.map(rowToJob)
}
export function getJobHistory(limit = 50): AiJob[] {
const db = getDb()
const rows = db
.prepare(
`SELECT * FROM ai_jobs
WHERE status IN ('completed', 'failed')
ORDER BY completed_at DESC
LIMIT ?`
)
.all(limit) as AiJobRow[]
return rows.map(rowToJob)
}
export function getJob(jobId: string): AiJob | null {
const db = getDb()
const row = db
.prepare('SELECT * FROM ai_jobs WHERE id = ?')
.get(jobId) as AiJobRow | undefined
return row ? rowToJob(row) : null
}
// ─── Actions ─────────────────────────────────────────────────────────────────
export function retryJob(jobId: string): boolean {
const db = getDb()
const result = db
.prepare(
`UPDATE ai_jobs SET status = 'queued', error = NULL, attempt = 0, started_at = NULL, completed_at = NULL
WHERE id = ? AND status = 'failed'`
)
.run(jobId)
if (result.changes > 0) {
wakeProcessor()
return true
}
return false
}
export function cancelJob(jobId: string): boolean {
const db = getDb()
const result = db
.prepare("DELETE FROM ai_jobs WHERE id = ? AND status = 'queued'")
.run(jobId)
return result.changes > 0
}
export function cancelAllQueued(): number {
const db = getDb()
const result = db
.prepare("DELETE FROM ai_jobs WHERE status = 'queued'")
.run()
return result.changes
}
export function clearJobHistory(): number {
const db = getDb()
const result = db
.prepare("DELETE FROM ai_jobs WHERE status IN ('completed', 'failed')")
.run()
return result.changes
}
// ─── Processor ───────────────────────────────────────────────────────────────
let processorRunning = false
let processorWake: (() => void) | null = null
function wakeProcessor(): void {
if (processorWake) {
processorWake()
} else if (!processorRunning) {
runProcessor()
}
}
async function processNextJob(): Promise<boolean> {
const db = getDb()
const row = db
.prepare(
`SELECT * FROM ai_jobs
WHERE status = 'queued'
ORDER BY created_at ASC
LIMIT 1`
)
.get() as AiJobRow | undefined
if (!row) return false
const now = Date.now()
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
const sourceLanguage = row.job_type === 'translate' ? row.error : null
db.prepare(
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
).run(now, row.id)
try {
switch (row.job_type) {
case 'tag':
await tagSingleItem(row.item_key)
break
case 'describe':
await generateItemDescription(row.item_key)
break
case 'extract':
await extractItemText(row.item_key)
break break
case 'translate': case 'translate':
await translateItemText(row.item_key, sourceLanguage || undefined) await translateItemText(row.item_key, sourceLanguage || undefined)

View File

@@ -4,7 +4,7 @@ import type { Library, Tag, TagCategory } from '@/types'
import { getDb } from './db' import { getDb } from './db'
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings' import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags' import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
import { getAiImagePath, getOcrImagePath, getVideoFramePaths } from './thumbnails' import { getAiImagePath, getVideoFramePaths } from './thumbnails'
import { findFile } from './media-utils' import { findFile } from './media-utils'
import { getLibrary, resolveLibraryRoot } from './libraries' import { getLibrary, resolveLibraryRoot } from './libraries'
@@ -171,8 +171,7 @@ async function callVisionApi(
endpoint: string, endpoint: string,
model: string, model: string,
base64Images: string[], base64Images: string[],
systemPrompt: string, systemPrompt: string
maxTokens: number,
): Promise<string[]> { ): Promise<string[]> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions' const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -196,7 +195,7 @@ async function callVisionApi(
})), })),
}, },
], ],
max_tokens: maxTokens, max_tokens: 8192,
temperature: 0.1, temperature: 0.1,
}), }),
}) })
@@ -339,7 +338,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
customInstruction: config.promptTagger || undefined, customInstruction: config.promptTagger || undefined,
}) })
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag) const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
const validIds = suggestedIds.filter((id) => validTagIds.has(id)) const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) { for (const tagId of validIds) {
@@ -360,8 +359,7 @@ async function callVisionApiText(
endpoint: string, endpoint: string,
model: string, model: string,
base64Images: string[], base64Images: string[],
systemPrompt: string, systemPrompt: string
maxTokens: number,
): Promise<string> { ): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions' const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -385,7 +383,7 @@ async function callVisionApiText(
})), })),
}, },
], ],
max_tokens: maxTokens, max_tokens: 8192,
temperature: 0.1, temperature: 0.1,
}), }),
}) })
@@ -412,8 +410,7 @@ async function callChatApiText(
endpoint: string, endpoint: string,
model: string, model: string,
systemPrompt: string, systemPrompt: string,
userMessage: string, userMessage: string
maxTokens: number,
): Promise<string> { ): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions' const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -431,7 +428,7 @@ async function callChatApiText(
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }, { role: 'user', content: userMessage },
], ],
max_tokens: maxTokens, max_tokens: 8192,
temperature: 0.1, temperature: 0.1,
}), }),
}) })
@@ -499,7 +496,7 @@ export async function generateItemDescription(itemKey: string): Promise<string>
: '' : ''
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}` const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt, config.maxTokensDescribe) const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey) db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
@@ -509,38 +506,36 @@ export async function generateItemDescription(itemKey: string): Promise<string>
// ─── Text extraction ───────────────────────────────────────────────────────── // ─── Text extraction ─────────────────────────────────────────────────────────
/** /**
* Run Tesseract OCR on a preprocessed image file. * Extract text (OCR) from an image using the vision model.
* Returns the extracted text and a mean confidence score (0100). * Only works for images in mixed libraries.
* A confidence of 0 with empty text means no recognisable text was found. * If the extracted text is not in the user's preferred language, auto-translates it.
* Returns { extractedText, translatedText }.
*/ */
async function extractWithTesseract( /**
imagePath: string, * Parse a structured extraction response from the AI.
languages: string, * Returns null if the response cannot be parsed as valid JSON with the expected shape.
): Promise<{ text: string; confidence: number }> { */
const { createWorker } = await import('tesseract.js') function parseStructuredExtraction(raw: string): { text: string; needsTranslation: boolean } | null {
const workerPath = path.join(process.cwd(), 'node_modules/tesseract.js/src/worker-script/node/index.js') const jsonMatch = raw.match(/\{[\s\S]*\}/)
const worker = await createWorker(languages, 1, { workerPath }) if (!jsonMatch) return null
try { try {
const { data } = await worker.recognize(imagePath) const parsed = JSON.parse(jsonMatch[0])
return { text: data.text.trim(), confidence: data.confidence } if (typeof parsed.text === 'string' && typeof parsed.needsTranslation === 'boolean') {
} finally { return { text: parsed.text, needsTranslation: parsed.needsTranslation }
await worker.terminate() }
} catch {
// fall through
} }
return null
} }
/** export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
* Extract text (OCR) from an image using the configured OCR mode:
* - hybrid: try Tesseract first; fall back to LLM if confidence is below threshold
* - tesseract: local Tesseract only, no LLM call
* - llm: LLM vision API only (original behaviour)
*
* Only works for images in mixed libraries.
* Translation is not performed automatically — call translateItemText() separately.
* Returns { extractedText, translatedText } where translatedText is always null.
*/
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId) const config = getEffectiveAiConfig(libraryId)
const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const db = getDb() const db = getDb()
const item = db const item = db
@@ -567,51 +562,72 @@ export async function extractItemText(itemKey: string, ocrLanguagesOverride?: st
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' }) throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
} }
const { ocrMode: configOcrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
const ocrMode = ocrModeOverride ?? configOcrMode
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
// ── Tesseract path ────────────────────────────────────────────────────────
if (ocrMode === 'tesseract' || ocrMode === 'hybrid') {
const ocrImagePath = await getOcrImagePath(resolvedMedia.path, libraryId)
const { text, confidence } = await extractWithTesseract(ocrImagePath, ocrLanguages)
const useTesseractResult = ocrMode === 'tesseract' || confidence >= ocrConfidenceThreshold
if (useTesseractResult) {
console.log(`[ocr] tesseract used for ${itemKey} (confidence=${confidence}, mode=${ocrMode})`)
if (!text) {
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
return { extractedText: '', translatedText: null }
}
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(text, itemKey)
return { extractedText: text, translatedText: null }
}
console.log(`[ocr] tesseract confidence too low (${confidence} < ${ocrConfidenceThreshold}), falling back to LLM for ${itemKey}`)
}
// ── LLM vision path ───────────────────────────────────────────────────────
const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId) const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')] const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
const preferredLanguage = getPreferredLanguage()
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : '' const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
console.log(`[ocr] llm used for ${itemKey} (mode=${ocrMode})`) // When a preferred language is configured, ask the AI to also flag whether translation is needed.
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract) // This avoids a separate translation API call for text already in the target language.
let systemPrompt: string
if (preferredLanguage) {
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction}
Respond ONLY with a valid JSON object — no markdown, no explanation:
{"needsTranslation": boolean, "text": "extracted text"}
Rules:
- Set needsTranslation to true if the text is NOT already written in ${preferredLanguage}.
- Set needsTranslation to false if the text IS in ${preferredLanguage}, or if there is no text.
- If there is no text in the image, use exactly: {"needsTranslation": false, "text": "[NO TEXT]"}`
} else {
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
}
const rawResponse = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
// Parse the response — structured JSON when a preferred language is set, plain text otherwise
let extractedText: string
let needsTranslation: boolean
if (preferredLanguage) {
const parsed = parseStructuredExtraction(rawResponse)
if (parsed) {
extractedText = parsed.text
needsTranslation = parsed.needsTranslation
} else {
// Malformed JSON fallback: treat raw response as plain text and attempt translation
extractedText = rawResponse
needsTranslation = true
}
} else {
extractedText = rawResponse
needsTranslation = false
}
if (!extractedText || extractedText === '[NO TEXT]') { if (!extractedText || extractedText === '[NO TEXT]') {
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey) db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
return { extractedText: '', translatedText: null } return { extractedText: '', translatedText: null }
} }
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(extractedText, itemKey) db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(extractedText, itemKey)
return { extractedText, translatedText: null } // Only translate if the extraction step determined the text is not already in the preferred language
let translatedText: string | null = null
if (preferredLanguage && needsTranslation) {
const translateModel = config.modelTranslate || config.model
try {
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
} catch (err) {
console.warn(`[ai-tagger] Translation failed for "${itemKey}":`, err instanceof Error ? err.message : err)
}
}
return { extractedText, translatedText }
} }
/** /**
@@ -634,13 +650,15 @@ export async function translateItemText(itemKey: string, sourceLanguage?: string
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' }) throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
} }
if (!row.extracted_text) { if (!row.extracted_text) {
return null throw Object.assign(new Error('No extracted text to translate'), { code: 'NO_TEXT' })
} }
const preferredLanguage = getPreferredLanguage() const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) return null if (!preferredLanguage) {
throw Object.assign(new Error('No preferred language configured'), { code: 'NO_LANGUAGE' })
}
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, sourceLanguage) const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, sourceLanguage)
if (translatedText) { if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey) db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
} }
@@ -656,14 +674,6 @@ export function updateExtractedText(itemKey: string, text: string): void {
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey) db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
} }
/**
* Update the ai_description of an item.
*/
export function updateAiDescription(itemKey: string, description: string): void {
const db = getDb()
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
}
/** /**
* Translate text to a target language using the chat API. * Translate text to a target language using the chat API.
* Returns null if the text is already in the target language. * Returns null if the text is already in the target language.
@@ -674,7 +684,6 @@ async function translateText(
text: string, text: string,
targetLanguage: string, targetLanguage: string,
customInstruction = '', customInstruction = '',
maxTokens = 8192,
sourceLanguage?: string, sourceLanguage?: string,
): Promise<string | null> { ): Promise<string | null> {
let systemPrompt: string let systemPrompt: string
@@ -684,7 +693,7 @@ async function translateText(
systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}` systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
} }
const result = await callChatApiText(endpoint, model, systemPrompt, text, maxTokens) const result = await callChatApiText(endpoint, model, systemPrompt, text)
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) { if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
return null return null

View File

@@ -46,8 +46,6 @@ const DEFAULT_PROMPT_EXTRACT =
'Be mindful of different colors of text that may indicate different speakers or emphasis.' 'Be mindful of different colors of text that may indicate different speakers or emphasis.'
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.' const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
export type OcrMode = 'hybrid' | 'tesseract' | 'llm'
export interface AiConfig { export interface AiConfig {
endpoint: string endpoint: string
model: string model: string
@@ -60,13 +58,6 @@ export interface AiConfig {
promptTagger: string promptTagger: string
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
ocrMode: OcrMode
ocrLanguages: string
ocrConfidenceThreshold: number
} }
export function getAiConfig(): AiConfig { export function getAiConfig(): AiConfig {
@@ -85,19 +76,9 @@ export function getAiConfig(): AiConfig {
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
const promptTranslateRaw = getSetting('ai_prompt_translate') const promptTranslateRaw = getSetting('ai_prompt_translate')
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
const maxTokensTag = parseInt(getSetting('ai_max_tokens_tag') ?? '8192', 10) || 8192
const maxTokensDescribe = parseInt(getSetting('ai_max_tokens_describe') ?? '8192', 10) || 8192
const maxTokensExtract = parseInt(getSetting('ai_max_tokens_extract') ?? '8192', 10) || 8192
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
const rawOcrMode = getSetting('ai_ocr_mode') ?? 'hybrid'
const ocrMode: OcrMode = rawOcrMode === 'tesseract' || rawOcrMode === 'llm' ? rawOcrMode : 'hybrid'
const ocrLanguages = getSetting('ai_ocr_languages') ?? 'eng'
const ocrConfidenceThreshold = parseInt(getSetting('ai_ocr_confidence_threshold') ?? '70', 10) || 70
return { return {
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled, endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
promptDescribe, promptTagger, promptExtract, promptTranslate, promptDescribe, promptTagger, promptExtract, promptTranslate,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
ocrMode, ocrLanguages, ocrConfidenceThreshold,
} }
} }
@@ -113,13 +94,6 @@ export function updateAiConfig(
promptTagger?: string, promptTagger?: string,
promptExtract?: string, promptExtract?: string,
promptTranslate?: string, promptTranslate?: string,
maxTokensTag?: number,
maxTokensDescribe?: number,
maxTokensExtract?: number,
maxTokensTranslate?: number,
ocrMode?: OcrMode,
ocrLanguages?: string,
ocrConfidenceThreshold?: number,
): void { ): void {
setSetting('ai_endpoint', endpoint) setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model) setSetting('ai_model', model)
@@ -132,13 +106,6 @@ export function updateAiConfig(
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger) if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract) if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate) if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
if (maxTokensTag !== undefined) setSetting('ai_max_tokens_tag', String(Math.max(1, Math.floor(maxTokensTag))))
if (maxTokensDescribe !== undefined) setSetting('ai_max_tokens_describe', String(Math.max(1, Math.floor(maxTokensDescribe))))
if (maxTokensExtract !== undefined) setSetting('ai_max_tokens_extract', String(Math.max(1, Math.floor(maxTokensExtract))))
if (maxTokensTranslate !== undefined) setSetting('ai_max_tokens_translate', String(Math.max(1, Math.floor(maxTokensTranslate))))
if (ocrMode !== undefined) setSetting('ai_ocr_mode', ocrMode)
if (ocrLanguages !== undefined) setSetting('ai_ocr_languages', ocrLanguages.trim() || 'eng')
if (ocrConfidenceThreshold !== undefined) setSetting('ai_ocr_confidence_threshold', String(Math.max(0, Math.min(100, Math.floor(ocrConfidenceThreshold)))))
} }
export function getPreferredLanguage(): string { export function getPreferredLanguage(): string {
@@ -160,10 +127,6 @@ export interface LibraryAiOverrides {
promptTagger: string promptTagger: string
promptExtract: string promptExtract: string
promptTranslate: string promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
} }
interface LibraryAiSettingsRow { interface LibraryAiSettingsRow {
@@ -175,10 +138,6 @@ interface LibraryAiSettingsRow {
prompt_tagger: string | null prompt_tagger: string | null
prompt_extract: string | null prompt_extract: string | null
prompt_translate: string | null prompt_translate: string | null
max_tokens_tag: number | null
max_tokens_describe: number | null
max_tokens_extract: number | null
max_tokens_translate: number | null
} }
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides { export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
@@ -195,10 +154,6 @@ export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
promptTagger: row?.prompt_tagger ?? '', promptTagger: row?.prompt_tagger ?? '',
promptExtract: row?.prompt_extract ?? '', promptExtract: row?.prompt_extract ?? '',
promptTranslate: row?.prompt_translate ?? '', promptTranslate: row?.prompt_translate ?? '',
maxTokensTag: row?.max_tokens_tag ?? null,
maxTokensDescribe: row?.max_tokens_describe ?? null,
maxTokensExtract: row?.max_tokens_extract ?? null,
maxTokensTranslate: row?.max_tokens_translate ?? null,
} }
} }
@@ -209,7 +164,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)' 'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
).run(libraryId) ).run(libraryId)
const stringFields: Record<string, string | undefined> = { const fields: Record<string, string | undefined> = {
model_tagging: overrides.modelTagging, model_tagging: overrides.modelTagging,
model_describe: overrides.modelDescribe, model_describe: overrides.modelDescribe,
model_extract: overrides.modelExtract, model_extract: overrides.modelExtract,
@@ -220,7 +175,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
prompt_translate: overrides.promptTranslate, prompt_translate: overrides.promptTranslate,
} }
for (const [col, val] of Object.entries(stringFields)) { for (const [col, val] of Object.entries(fields)) {
if (val !== undefined) { if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run( db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === '' ? null : val, val === '' ? null : val,
@@ -228,22 +183,6 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
) )
} }
} }
const numberFields: Record<string, number | null | undefined> = {
max_tokens_tag: overrides.maxTokensTag,
max_tokens_describe: overrides.maxTokensDescribe,
max_tokens_extract: overrides.maxTokensExtract,
max_tokens_translate: overrides.maxTokensTranslate,
}
for (const [col, val] of Object.entries(numberFields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === null ? null : Math.max(1, Math.floor(val)),
libraryId,
)
}
}
} }
export function getEffectiveAiConfig(libraryId: string): AiConfig { export function getEffectiveAiConfig(libraryId: string): AiConfig {
@@ -261,13 +200,6 @@ export function getEffectiveAiConfig(libraryId: string): AiConfig {
promptTagger: overrides.promptTagger || global.promptTagger, promptTagger: overrides.promptTagger || global.promptTagger,
promptExtract: overrides.promptExtract || global.promptExtract, promptExtract: overrides.promptExtract || global.promptExtract,
promptTranslate: overrides.promptTranslate || global.promptTranslate, promptTranslate: overrides.promptTranslate || global.promptTranslate,
maxTokensTag: overrides.maxTokensTag ?? global.maxTokensTag,
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
ocrMode: global.ocrMode,
ocrLanguages: global.ocrLanguages,
ocrConfidenceThreshold: global.ocrConfidenceThreshold,
} }
} }

View File

@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
} }
// Auth guard result type // Auth guard result type
type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' } type AuthSuccess = { session: IronSession<SessionData> }
type AuthResult = AuthSuccess | NextResponse type AuthResult = AuthSuccess | NextResponse
// Read-only session from an API route request (throwaway response) // Read-only session from an API route request (throwaway response)
@@ -100,22 +100,13 @@ export async function requireLibraryAccess(req: NextRequest, libraryId: string):
if (!session.userId) { if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (session.role === 'admin') return { session, accessLevel: 'admin' } if (session.role === 'admin') return { session }
// Lazy import to avoid pulling DB into edge contexts // Lazy import to avoid pulling DB into edge contexts
const { getLibraryAccessLevel } = await import('./users') const { getPermittedLibraryIds } = await import('./users')
const accessLevel = getLibraryAccessLevel(session.userId, libraryId) const permitted = getPermittedLibraryIds(session.userId)
if (!accessLevel) { if (!permitted.includes(libraryId)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
return { session, accessLevel } return { session }
}
export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
const result = await requireLibraryAccess(req, libraryId)
if (result instanceof NextResponse) return result
if (result.accessLevel === 'read') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return result
} }

View File

@@ -106,7 +106,6 @@ function initDb(db: Database.Database): void {
migrateMediaItemsAiFields(db) migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db) migrateLibraryAiSettings(db)
migrateAiJobs(db) migrateAiJobs(db)
migrateLibraryPermissionsAccessLevel(db)
seedAppSettings(db) seedAppSettings(db)
} }
@@ -120,10 +119,6 @@ function seedAppSettings(db: Database.Database): void {
ai_model: '', ai_model: '',
preferred_language: 'English', preferred_language: 'English',
ai_max_retries: '3', ai_max_retries: '3',
ai_max_tokens_tag: '8192',
ai_max_tokens_describe: '8192',
ai_max_tokens_extract: '8192',
ai_max_tokens_translate: '8192',
} }
const insert = db.prepare( const insert = db.prepare(
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)' 'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
@@ -281,19 +276,6 @@ function migrateLibraryAiSettings(db: Database.Database): void {
prompt_translate TEXT prompt_translate TEXT
); );
`) `)
// Add max_tokens columns if they don't exist yet
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_ai_settings'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('max_tokens_tag')) {
db.exec(`
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_tag INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_describe INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_extract INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_translate INTEGER;
`)
}
} }
function migrateLibrariesType(db: Database.Database): void { function migrateLibrariesType(db: Database.Database): void {
@@ -319,15 +301,6 @@ function migrateLibrariesType(db: Database.Database): void {
} }
} }
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('access_level')) {
db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
}
}
function migrateAiJobs(db: Database.Database): void { function migrateAiJobs(db: Database.Database): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS ai_jobs ( CREATE TABLE IF NOT EXISTS ai_jobs (
@@ -348,12 +321,4 @@ function migrateAiJobs(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status); CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at); CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
`) `)
// Add payload column if not present
const aiJobsRow = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'")
.get() as { sql: string } | undefined
if (aiJobsRow && !aiJobsRow.sql.includes('payload')) {
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
}
} }

View File

@@ -60,19 +60,6 @@ async function generateAiImage(src: string, dest: string): Promise<void> {
fs.renameSync(tmp, dest) fs.renameSync(tmp, dest)
} }
/** Generate a grayscale, contrast-normalised PNG for local OCR (Tesseract).
* PNG is lossless and avoids JPEG artefacts that can degrade OCR accuracy. */
async function generateOcrImage(src: string, dest: string): Promise<void> {
const tmp = dest + '.tmp'
await sharp(src)
.resize(AI_IMAGE_WIDTH, undefined, { withoutEnlargement: true })
.grayscale()
.normalise()
.png()
.toFile(tmp)
fs.renameSync(tmp, dest)
}
/** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */ /** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */
function run(bin: string, args: string[]): Promise<void> { function run(bin: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -203,24 +190,6 @@ export async function getAiImagePath(
return cacheFile return cacheFile
} }
/**
* Returns the absolute path to a preprocessed PNG suitable for local OCR.
* The image is converted to grayscale and contrast-normalised for better
* Tesseract accuracy. Cached with an `_ocr` suffix.
*/
export async function getOcrImagePath(
absoluteFilePath: string,
libraryId: string
): Promise<string> {
ensureCacheDir()
const key = cacheKey(libraryId, absoluteFilePath)
const cacheFile = path.join(CACHE_DIR, key + '_ocr.png')
const cached = getCachedPath(cacheFile, absoluteFilePath)
if (cached) return cached
await generateOcrImage(absoluteFilePath, cacheFile)
return cacheFile
}
/** /**
* 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).

View File

@@ -3,7 +3,6 @@ import path from 'path'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import { getDb } from './db' import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
import { parseTvShowNfo } from './nfo'
function isVideoFile(name: string): boolean { function isVideoFile(name: string): boolean {
return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase()) return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase())
@@ -53,7 +52,6 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
const posterFile = findFile(seriesPath, /^(poster|folder)$/i) const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i) const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
const nfo = parseTvShowNfo(path.join(seriesPath, 'tvshow.nfo'))
const seasonDirs = readDirs(seriesPath) const seasonDirs = readDirs(seriesPath)
const seasonDirCount = seasonDirs.filter((sd) => { const seasonDirCount = seasonDirs.filter((sd) => {
@@ -69,11 +67,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
series.push({ series.push({
id, id,
title: nfo?.title ?? dirName, title: dirName,
year: nfo?.year ?? null, year: null,
plot: nfo?.plot ?? null, plot: null,
genres: nfo?.genres ?? [], genres: [],
status: nfo?.status ?? null, status: null,
posterUrl: posterFile posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile)) ? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null, : null,

View File

@@ -77,60 +77,43 @@ export function listUsers(): User[] {
})) }))
} }
export interface LibraryPermission { export function getPermittedLibraryIds(userId: string): string[] {
libraryId: string
accessLevel: 'read' | 'write'
}
export function getLibraryPermissions(userId: string): LibraryPermission[] {
const db = getDb() const db = getDb()
const rows = db const rows = db
.prepare('SELECT library_id, access_level FROM library_permissions WHERE user_id = ?') .prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
.all(userId) as { library_id: string; access_level: string }[] .all(userId) as { library_id: string }[]
return rows.map((r) => ({ libraryId: r.library_id, accessLevel: r.access_level as 'read' | 'write' })) return rows.map((r) => r.library_id)
} }
export function getLibraryAccessLevel(userId: string, libraryId: string): 'read' | 'write' | null { export function setLibraryPermissions(userId: string, libraryIds: string[]): void {
const db = getDb()
const row = db
.prepare('SELECT access_level FROM library_permissions WHERE user_id = ? AND library_id = ?')
.get(userId, libraryId) as { access_level: string } | undefined
if (!row) return null
return row.access_level as 'read' | 'write'
}
export function setLibraryPermissions(userId: string, permissions: LibraryPermission[]): void {
const db = getDb() const db = getDb()
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId) db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
const insert = db.prepare( const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)')
'INSERT INTO library_permissions (user_id, library_id, access_level) VALUES (?, ?, ?)' for (const libraryId of libraryIds) {
) insert.run(userId, libraryId)
for (const { libraryId, accessLevel } of permissions) {
insert.run(userId, libraryId, accessLevel)
} }
}) })
tx() tx()
} }
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] { export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
if (role === 'admin') return getLibraries().map((l) => ({ ...l, accessLevel: 'admin' as const })) if (role === 'admin') return getLibraries()
const db = getDb() const db = getDb()
const rows = db const rows = db
.prepare( .prepare(
`SELECT l.id, l.name, l.path, l.type, l.cover_ext, lp.access_level `SELECT l.id, l.name, l.path, l.type, l.cover_ext
FROM libraries l FROM libraries l
INNER JOIN library_permissions lp ON lp.library_id = l.id INNER JOIN library_permissions lp ON lp.library_id = l.id
WHERE lp.user_id = ? WHERE lp.user_id = ?
ORDER BY l.name ASC` ORDER BY l.name ASC`
) )
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null; access_level: string }[] .all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null }[]
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
path: r.path, path: r.path,
type: r.type as Library['type'], type: r.type as Library['type'],
coverExt: r.cover_ext, coverExt: r.cover_ext,
accessLevel: r.access_level as 'read' | 'write',
})) }))
} }

View File

@@ -6,7 +6,6 @@ export interface Library {
path: string path: string
type: LibraryType type: LibraryType
coverExt: string | null coverExt: string | null
accessLevel?: 'admin' | 'read' | 'write'
} }
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android' export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'
@@ -45,7 +44,6 @@ export interface FileEntry {
mediaType: MediaType | null mediaType: MediaType | null
url: string | null url: string | null
thumbnailUrl: string | null thumbnailUrl: string | null
hasExtractedText?: boolean
} }
export interface Movie { export interface Movie {