Compare commits

...

17 Commits

Author SHA1 Message Date
Garret Patti
fbcd592609 Use game cover as series cover if series cover is not available
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
2026-04-18 12:44:01 -04:00
7b76e3d900 Merge pull request 'maintainability' (#30) from maintainability into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/30
2026-04-18 15:55:54 +00:00
Garret Patti
2ea02b197b expand user permissions 2026-04-18 11:48:01 -04:00
Garret Patti
8f84da7e2f add keyboard navigation 2026-04-18 11:18:40 -04:00
Garret Patti
625e256944 reduce repeated tag selector code 2026-04-18 11:10:26 -04:00
152bc12427 Merge pull request 'more-ui-adjustments' (#29) from more-ui-adjustments into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 58s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/29
2026-04-18 04:38:33 +00:00
Garret Patti
345a05e42a fix TV show metadata refresh 2026-04-18 00:38:04 -04:00
Garret Patti
0de839393a fix tv navigation 2026-04-18 00:22:02 -04:00
Garret Patti
0ff3ed8ac9 add gameview series navigation 2026-04-18 00:14:18 -04:00
Garret Patti
b2e9df8ab8 add gameview navigation 2026-04-17 23:55:33 -04:00
b774cba046 Merge pull request 'consistent-ui-across-libraries' (#28) from consistent-ui-across-libraries into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/28
2026-04-15 12:32:04 +00:00
Garret Patti
5b5c3453d2 add download buttons to tv 2026-04-15 08:30:41 -04:00
Garret Patti
37dcb79546 fix tv view 2026-04-15 08:16:38 -04:00
c2135747b5 Merge pull request 'image-viewer-improvements' (#27) from image-viewer-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/27
2026-04-14 23:56:16 +00:00
Garret Patti
afcf740f63 update ai buttons 2026-04-14 19:55:44 -04:00
Garret Patti
dae33a36bc remember tag selector state 2026-04-14 19:17:22 -04:00
Garret Patti
a379e94bce media viewer consistency 2026-04-14 18:45:06 -04:00
34 changed files with 1975 additions and 1365 deletions

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
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 })
}
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)

View File

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

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
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 })
}
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,18 +33,37 @@ export async function GET(request: NextRequest) {
? scanDirectoryRecursive(root, libraryId, subpath)
: scanDirectory(root, libraryId, subpath)
// Annotate image entries with whether they have extracted text
// 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' || e.mediaType !== 'image') return 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)

View File

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

View File

@@ -120,7 +120,46 @@ export async function POST(request: NextRequest) {
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') {

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { getLibrary } from '@/lib/libraries'
import { notFound, redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { getPermittedLibraryIds } from '@/lib/users'
import { getLibraryAccessLevel } from '@/lib/users'
import GamesView from '@/components/games/GamesView'
import MixedView from '@/components/mixed/MixedView'
import MoviesView from '@/components/movies/MoviesView'
@@ -23,13 +23,16 @@ export default async function LibraryPage({ params, searchParams }: Props) {
const library = getLibrary(id)
if (!library) notFound()
let readOnly = false
if (session.role !== 'admin') {
const permitted = getPermittedLibraryIds(session.userId)
if (!permitted.includes(id)) notFound()
const accessLevel = getLibraryAccessLevel(session.userId, id)
if (!accessLevel) notFound()
readOnly = accessLevel === 'read'
}
return (
<div>
{library.type !== 'mixed' && (
<div className="flex items-center gap-2 mb-6">
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
Libraries
@@ -44,11 +47,17 @@ export default async function LibraryPage({ params, searchParams }: Props) {
</div>
)}
</div>
)}
{library.type === 'mixed' && session.role === 'admin' && (
<div className="flex justify-end mb-2">
<ScanLibraryButton libraryId={id} />
</div>
)}
{library.type === 'games' && <GamesView libraryId={id} />}
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
{library.type === 'movies' && <MoviesView libraryId={id} />}
{library.type === 'tv' && <TvView libraryId={id} />}
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
</div>
)
}

View File

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

View File

@@ -2,7 +2,8 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game, GameFile, GamePlatform } from '@/types'
import TagSelector from '@/components/tags/TagSelector'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
// Import SVG icons
import WindowsIcon from '@/app/icons/windows.svg'
@@ -29,12 +30,15 @@ interface Props {
game: Game
libraryId: string
onClose: () => void
onPrev?: () => void
onNext?: () => void
onTagsChanged?: () => void
onCoverUploaded?: () => void
onDeleted?: (gameId: string) => void
readOnly?: boolean
}
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const screenshotInputRef = useRef<HTMLInputElement>(null)
@@ -46,6 +50,9 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const [aiDescription, setAiDescription] = useState<string | null>(null)
// Screenshots state
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
@@ -54,6 +61,8 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
const [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(() => {
setScreenshotsLoading(true)
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
@@ -65,6 +74,14 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
useEffect(() => { fetchScreenshots() }, [fetchScreenshots])
useEffect(() => {
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 files = Array.from(e.target.files ?? [])
if (files.length === 0) return
@@ -106,11 +123,14 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
return
}
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return }
if (renaming) { setRenaming(false); return }
if (editingImages) { setEditingImages(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose()
}
}
@@ -120,7 +140,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
document.removeEventListener('keydown', handleKey)
document.body.style.overflow = ''
}
}, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length])
}, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
// Close menu on outside click
useEffect(() => {
@@ -153,13 +173,21 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
onClick={handleOverlayClick}
>
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
{/* ── Left pane — relative container for floating controls ── */}
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
{/* Scrollable card area */}
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{editingImages ? (
<ImageEditor
@@ -170,17 +198,6 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
/>
) : (
<>
{/* 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>
{/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
@@ -195,13 +212,13 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
{/* Info */}
<div className="p-5">
{/* Title row with kebab menu */}
<div className="flex items-center gap-2 mb-4">
<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 */}
<div className="relative flex-shrink-0" ref={menuRef}>
{!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"
@@ -253,8 +270,15 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
)}
</div>
)}
</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 && (
@@ -364,6 +388,13 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
</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 */}
@@ -439,20 +470,75 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
onChange={handleScreenshotUpload}
/>
</div>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={game.item_key!} onTagsChanged={onTagsChanged} />
</div>
</div>
</>
)}
</div>
</div>
{/* Lightbox */}
{/* 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
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>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && (
<MediaTagPanel
itemKey={game.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div>
{/* Screenshot lightbox (z-60, sits above the modal) */}
{lightboxIndex !== null && (
<div
className="fixed inset-0 flex items-center justify-center"

View File

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

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import TagSelector from '@/components/tags/TagSelector'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
interface Props {
url: string
@@ -12,15 +12,16 @@ interface Props {
itemKey?: string
onTagsChanged?: () => void
onAiTag?: () => Promise<void>
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
}
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) {
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(false)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal
// Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null)
@@ -33,8 +34,10 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
const [savingText, setSavingText] = useState(false)
const [sourceLanguage, setSourceLanguage] = useState('')
// Description state (moved from TagSelector)
// 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)
@@ -67,6 +70,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
})
.catch(() => {})
}, [itemKey])
@@ -112,6 +116,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
@@ -168,292 +173,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return (
<div
ref={overlayRef}
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', maxHeight: '100vh' }}
onClick={handleOverlayClick}
>
{/* Toolbar — collapses to just filename + text overlay when tag panel is open */}
<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">
{/* Text overlay button — always shown when text exists */}
{extractedText && (
<button
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
className="w-12 h-12 rounded-full flex items-center justify-center transition-colors"
style={{
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
title="Display text"
>
<svg width="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="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/>
</svg>
</button>
)}
{/* These buttons only show in the toolbar when the tag panel is closed */}
{!showTags && (
<>
{itemKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
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="Show tags"
title="Tags"
>
🏷
</button>
)}
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}}
disabled={aiTagging}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
fontSize: '1.5rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label="AI Tag this image"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? (
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}></span>
) : '✨'}
</button>
)}
<button
onClick={onClose}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
>
</button>
</>
)}
</div>
</div>
{showTags ? (
<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>
</div>
)}
</div>
{/* Tag panel */}
<div
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Panel header — hide panel + AI tagger + close lightbox */}
<div className="flex items-center justify-between flex-shrink-0">
<button
onClick={() => setShowTags(false)}
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>
<div className="flex items-center gap-1.5">
{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={`${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 this image"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</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>
</div>
{/* Description section */}
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Description
</p>
{aiDescription && (
<p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}>
{aiDescription}
</p>
)}
<div className="flex items-center gap-1.5">
<button
onClick={handleGenerateDescription}
disabled={generatingDesc || descPending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
color: descPending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!generatingDesc && !descPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
if (!descPending) {
;(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…' : descPending ? '⟳ Queued…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)}
</div>
</div>
{/* Text extraction section — only for images */}
{isImage && (
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Text Extraction
</p>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={async () => {
const callExtract = async (modeOverride: string) => {
setExtracting(true)
setExtractError(null)
setExtractPending(false)
@@ -463,7 +183,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey,
...(ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
ocrMode: modeOverride,
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
}),
})
if (res.status === 202) {
@@ -485,13 +206,259 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
} finally {
setExtracting(false)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
onClick={handleOverlayClick}
>
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
<div className="relative flex-1 min-h-0 min-w-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="absolute inset-0 w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
{/* ── Floating controls ── */}
{/* Filename pill — bottom-left */}
<div
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
>
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
{name}
</span>
</div>
{/* Tags + Close — top-right */}
<div
className="absolute top-4 right-4 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
{itemKey && !showTags && (
<button
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
{!showTags && (
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
title="Close"
>
</button>
)}
</div>
{/* Text display button — bottom-right, hidden when panel open */}
{!showTags && extractedText && (
<button
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
className={`absolute bottom-4 right-4 ${smallBtn}`}
style={{
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
title="Display text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTags && (
<MediaTagPanel
itemKey={itemKey!}
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
>
{/* Description section */}
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Description
</p>
<button
onClick={handleGenerateDescription}
disabled={generatingDesc || descPending}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
color: descPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<textarea
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
placeholder="No description yet…"
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: '3.5rem',
maxHeight: '8rem',
fontFamily: 'inherit',
}}
/>
{editedDescription !== (aiDescription ?? '') && (
<button
onClick={async () => {
setSavingDesc(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
})
setAiDescription(editedDescription)
} finally {
setSavingDesc(false)
}
}}
disabled={savingDesc}
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingDesc ? '⟳ Saving…' : 'Save'}
</button>
)}
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
</div>
{/* Text extraction section — only for images */}
{isImage && (
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Text Extraction
</p>
<button
onClick={() => callExtract('llm')}
disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
color: extractPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="Extract text with AI"
title="Extract with AI (skips OCR)"
>
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!extracting && !extractPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
@@ -499,15 +466,12 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
}
}}
onMouseLeave={(e) => {
if (!extractPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
</button>
{ocrMode && ocrMode !== 'llm' && (
<input
type="text"
value={ocrLanguageInput}
@@ -522,12 +486,9 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
}}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/>
)}
</div>
{extractError && (
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
)}
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
{extractedText && (
<div className="flex flex-col gap-2">
@@ -653,70 +614,9 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
)}
</div>
)}
{/* Tags section */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<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} hideDescription />
</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>
</MediaTagPanel>
)}
</div>
)}
</div>
)
}

View File

@@ -11,7 +11,9 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
libraryName: string
initialPath: string
readOnly?: boolean
}
type ModalState =
@@ -21,18 +23,21 @@ type ModalState =
type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, initialPath }: Props) {
export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<ModalState>(null)
const [modalShowTags, setModalShowTags] = useState(false)
const [tagPanel, setTagPanel] = useState<TagPanelState>(null)
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
const [recursiveLoading, setRecursiveLoading] = useState(false)
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
@@ -338,12 +343,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<div className="flex-1 min-w-0">
{/* Breadcrumb */}
<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
onClick={() => loadPath('')}
className="transition-colors"
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
>
Root
{libraryName}
</button>
{breadcrumbs.map((segment, i) => {
const isLast = i === breadcrumbs.length - 1
@@ -469,6 +482,18 @@ export default function MixedView({ libraryId, initialPath }: Props) {
}
}}
onTranslate={async (e) => {
if (e.type === 'directory') {
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/ai-tagging/translate-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, path: dirRel }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
} else {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
@@ -479,6 +504,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
}
}}
onDelete={(e) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
@@ -520,10 +546,13 @@ export default function MixedView({ libraryId, initialPath }: Props) {
name={modal.name}
itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
onClose={() => { setModal(null); setModalShowTags(false) }}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={modal.itemKey ? async () => {
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -544,10 +573,13 @@ export default function MixedView({ libraryId, initialPath }: Props) {
name={modal.name}
itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
onClose={() => { setModal(null); setModalShowTags(false) }}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={async () => {
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
readOnly={readOnly}
onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -746,7 +778,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
</button>
{/* Kebab menu — bottom-right, shown on hover */}
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && (
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory')) || (onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText)) && (
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
@@ -917,7 +949,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
🔍 Extract Text for Folder
</button>
)}
{onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && (
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
<button
onClick={(e) => {
e.stopPropagation()
@@ -934,7 +966,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
🌐 Translate
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
</button>
)}
{onRename && (

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import { useUserSettings } from '@/hooks/useUserSettings'
interface Props {
@@ -14,18 +14,21 @@ interface Props {
onTagsChanged?: () => void
onAiTag?: () => Promise<void>
context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
}
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed' }: Props) {
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
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 overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(false)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -50,21 +53,26 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
return (
<div
ref={overlayRef}
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', maxHeight: '100vh' }}
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
onClick={handleOverlayClick}
>
{/* Toolbar — collapses to just filename when tag panel is open */}
<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)' }}>
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}>
{/* ── Video column ── */}
<div className="flex flex-col flex-1 min-h-0 min-w-0 relative">
{/* Toolbar — scoped to this column's width */}
<div className="flex items-center justify-between px-3 py-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-sm truncate mr-2" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
{!showTags && (
<div className="flex items-center gap-2 flex-shrink-0">
{itemKey && (
<div className="flex items-center gap-1.5 flex-shrink-0">
{itemKey && !showTags && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
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)'}
@@ -74,175 +82,24 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
🏷
</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>
)}
{!showTags && (
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
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>
)}
</div>
{showTags ? (
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
{/* Video */}
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
<video
key={url}
src={url}
controls
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full object-contain rounded-lg"
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>
{/* Tag panel */}
<div
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Panel header — hide panel + AI tagger + close */}
<div className="flex items-center justify-between flex-shrink-0">
<button
onClick={() => setShowTags(false)}
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>
<div className="flex items-center gap-1.5">
{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={`${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 this video"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</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>
</div>
{/* Tags section */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<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>
) : (
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
{/* Video area — single element, never remounts on panel toggle */}
<div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
<video
key={url}
src={url}
@@ -250,14 +107,18 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full max-w-4xl object-contain rounded-lg"
playsInline
className="w-full h-full object-contain"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Prev/Next — positioned relative to the full column height (incl. toolbar)
so they align with ImageLightbox's buttons which span the full viewport */}
{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"
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"
>
@@ -267,7 +128,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
{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"
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"
>
@@ -275,7 +136,19 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
</button>
)}
</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>
)
}

View File

@@ -2,7 +2,8 @@
import { useEffect, useRef, useState } from 'react'
import type { Movie } from '@/types'
import TagSelector from '@/components/tags/TagSelector'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
interface Props {
@@ -14,9 +15,10 @@ interface Props {
onTagsChanged?: () => void
onDeleted: (movieId: string) => void
onMetadataRefreshed?: () => void
readOnly?: boolean
}
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false)
@@ -32,15 +34,22 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
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(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return }
if (warnRefresh) { setWarnRefresh(false); return }
if (editing) { setEditing(false); return }
if (renaming) { setRenaming(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose()
}
}
@@ -50,7 +59,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
document.removeEventListener('keydown', handleKey)
document.body.style.overflow = ''
}
}, [onClose, menuOpen, confirming, editing, warnRefresh, renaming])
}, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
// Close menu on outside click
useEffect(() => {
@@ -132,7 +141,6 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
const handleStartRename = () => {
setMenuOpen(false)
// movie.id is the encoded folder name
setRenameName(decodeURIComponent(movie.id))
setRenameError(null)
setRenaming(true)
@@ -187,51 +195,22 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
onClick={handleOverlayClick}
>
<div
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>
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
{/* Prev / Next buttons on the detail card */}
{onPrev && (
<button
onClick={onPrev}
className="absolute top-3 left-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="Previous movie"
{/* ── Left pane — relative container for floating controls ── */}
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
{/* Scrollable card area */}
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
</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 */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
@@ -260,7 +239,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</span>
)}
{/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
{!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"
@@ -315,7 +294,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</button>
</div>
)}
</div>
</div>}
</div>
{/* Rename inline input */}
@@ -500,10 +479,18 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</div>
)}
{/* Play button */}
{/* 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 items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ 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)')}
@@ -511,15 +498,84 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
<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>
{/* 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} />
{/* 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>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && (
<MediaTagPanel
itemKey={movie.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div>
</div>
)

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
'use client'
import { useState } from 'react'
import TagSelector from './TagSelector'
interface Props {
itemKey: string
onHide: () => void
onClose: () => void
onTagsChanged?: () => void
externalRefreshKey?: number
onAiTag?: () => Promise<void>
disabled?: boolean
disabledMessage?: string
readOnly?: boolean
children?: React.ReactNode
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
export default function MediaTagPanel({
itemKey,
onHide,
onClose,
onTagsChanged,
externalRefreshKey = 0,
onAiTag,
disabled,
disabledMessage,
readOnly,
children,
}: Props) {
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
const handleAiTag = async () => {
if (!onAiTag) return
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setInternalRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}
return (
<div
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Panel header — hide | ✕ close */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={onHide}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
{/* Scrollable content */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
{children}
{disabled || !itemKey ? (
disabledMessage ? (
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
{disabledMessage}
</p>
) : null
) : (
<>
{/* Tags section heading + optional AI button */}
<div className="flex items-center justify-between mt-4 mb-3">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{onAiTag && (
<button
onClick={handleAiTag}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
)}
</div>
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
<TagSelector
itemKey={itemKey}
onTagsChanged={onTagsChanged}
refreshKey={internalRefreshKey + externalRefreshKey}
hideDescription
readOnly={readOnly}
/>
</>
)}
</div>
</div>
)
}

View File

@@ -9,6 +9,7 @@ interface Props {
onTagsChanged?: () => void
refreshKey?: number
hideDescription?: boolean
readOnly?: boolean
}
interface AllTags {
@@ -16,7 +17,7 @@ interface AllTags {
tags: Tag[]
}
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription }: Props) {
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [],
categories: [],
@@ -277,6 +278,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
style={{ backgroundColor: 'var(--surface-hover)' }}
>
{tag.name}
{!readOnly && (
<button
onClick={() => toggleTag(tag)}
className="ml-0.5 leading-none transition-colors"
@@ -287,13 +289,14 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
>
</button>
)}
</span>
))}
</span>
)
})}
{ungrouped.map((tag) => (
<TagBadge key={tag.id} tag={tag} onRemove={() => toggleTag(tag)} />
<TagBadge key={tag.id} tag={tag} onRemove={readOnly ? undefined : () => toggleTag(tag)} />
))}
</>
)
@@ -302,7 +305,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
)}
{/* Tag picker grouped by category */}
<div className="flex flex-col gap-2">
{!readOnly && <div className="flex flex-col gap-2">
{all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? ''
@@ -531,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
</button>
)}
</div>
</div>
</div>}
</div>
)
}

View File

@@ -9,9 +9,10 @@ interface Props {
onTag?: () => void
onDelete?: () => void
onRename?: (newName: string) => Promise<boolean>
downloadUrl?: string
}
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename }: Props) {
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename, downloadUrl }: Props) {
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false)
@@ -79,7 +80,7 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
</button>
)}
{/* Kebab menu */}
{onDelete && (
{(onDelete || downloadUrl) && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
@@ -94,6 +95,19 @@ export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRenam
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
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 && (
<button
onClick={(e) => {

View File

@@ -5,18 +5,21 @@ import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import TagSelector from '@/components/tags/TagSelector'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import EpisodeCard from './EpisodeCard'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
readOnly?: boolean
}
type ViewLevel = 'series' | 'seasons' | 'episodes'
export default function TvView({ libraryId }: Props) {
export default function TvView({ libraryId, readOnly }: Props) {
const [view, setView] = useState<ViewLevel>('series')
const [series, setSeries] = useState<TvSeries[]>([])
const [seasons, setSeasons] = useState<TvSeason[]>([])
@@ -31,7 +34,11 @@ export default function TvView({ libraryId }: Props) {
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedSeriesIndex, setSelectedSeriesIndex] = useState<number | null>(null)
const [selectedSeasonIndex, setSelectedSeasonIndex] = useState<number | null>(null)
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
@@ -48,7 +55,12 @@ export default function TvView({ libraryId }: Props) {
const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
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 smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => {
@@ -87,6 +99,7 @@ export default function TvView({ libraryId }: Props) {
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
const openSeries = (s: TvSeries) => {
setSelectedSeriesIndex(filteredSeries.indexOf(s))
setSelectedSeries(s)
setView('seasons')
setLoading(true)
@@ -96,18 +109,17 @@ export default function TvView({ libraryId }: Props) {
.then((data: TvSeason[]) => {
setSeasons(data)
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) })
}
const openSeason = (season: TvSeason) => {
const openSeason = (season: TvSeason, index?: number) => {
setSelectedSeasonIndex(index ?? seasons.indexOf(season))
setSelectedSeason(season)
setView('episodes')
if (showTagPanel) {
setTagPanelDisabled(true)
}
setLoading(true)
setError(null)
fetch(
@@ -134,14 +146,24 @@ export default function TvView({ libraryId }: Props) {
setView('series')
setSelectedSeries(null)
setSelectedSeason(null)
setSelectedSeriesIndex(null)
setSelectedSeasonIndex(null)
setMenuOpen(false)
setConfirming(false)
setShowTagPanel(false)
setTagPanelItemKey(null)
setTagPanelDisabled(false)
}
const goToSeasons = () => {
setView('seasons')
setSelectedSeason(null)
setSelectedSeasonIndex(null)
setConfirming(false)
if (showTagPanel && selectedSeries?.item_key) {
setTagPanelItemKey(selectedSeries.item_key)
setTagPanelDisabled(false)
}
}
const handleDeleteSeries = () => {
@@ -164,11 +186,18 @@ export default function TvView({ libraryId }: Props) {
setRefreshingMeta(true)
setWarnRefresh(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
const currentId = selectedSeries.id
fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`,
{ method: 'POST' }
)
.then(() => fetchSeries())
.then(() => fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`))
.then((r) => r.json())
.then((data: TvSeries[]) => {
setSeries(data)
const updated = data.find((s) => s.id === currentId)
if (updated) setSelectedSeries(updated)
})
.finally(() => setRefreshingMeta(false))
}
@@ -312,6 +341,40 @@ export default function TvView({ libraryId }: Props) {
}
}
// Escape key + body scroll lock when modal is open
useEffect(() => {
if (view === 'series') return
const handleKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (menuOpen) { setMenuOpen(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
if (view === 'episodes') {
setView('seasons')
setSelectedSeason(null)
setConfirming(false)
if (selectedSeries?.item_key) {
setTagPanelItemKey(selectedSeries.item_key)
setTagPanelDisabled(false)
}
return
}
setView('series')
setSelectedSeries(null)
setSelectedSeason(null)
setMenuOpen(false)
setConfirming(false)
setShowTagPanel(false)
setTagPanelItemKey(null)
setTagPanelDisabled(false)
}
document.addEventListener('keydown', handleKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKey)
document.body.style.overflow = ''
}
}, [view, menuOpen, showTagPanel, selectedSeries])
const filtersActive = search !== '' || selectedTagIds.size > 0
const filteredSeries = series.filter((s) => {
@@ -336,6 +399,28 @@ export default function TvView({ libraryId }: Props) {
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
if (playingEpisode && playingEpisodeIndex !== null) {
@@ -350,6 +435,7 @@ export default function TvView({ libraryId }: Props) {
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
context="tv"
readOnly={readOnly}
/>
)
}
@@ -502,9 +588,76 @@ export default function TvView({ libraryId }: Props) {
)}
</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 && (
<div>
{/* Series info header */}
@@ -682,6 +835,11 @@ export default function TvView({ libraryId }: Props) {
{selectedSeries.plot && (
<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>
@@ -756,7 +914,7 @@ export default function TvView({ libraryId }: Props) {
{seasons.map((season) => (
<button
key={season.id}
onClick={() => openSeason(season)}
onClick={() => openSeason(season, seasons.indexOf(season))}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
@@ -792,7 +950,7 @@ export default function TvView({ libraryId }: Props) {
)}
{view === 'episodes' && selectedSeason && (
<div>
<div className="p-4">
{loading ? (
<EpisodeLoadingGrid />
) : error ? (
@@ -808,7 +966,8 @@ export default function TvView({ libraryId }: Props) {
key={ep.id}
episode={ep}
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
onTag={() => { setTagPanelItemKey(ep.item_key!); setTagPanelDisabled(false); setShowTagPanel(true) }}
downloadUrl={`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`}
onDelete={() => {
fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
@@ -838,42 +997,91 @@ export default function TvView({ libraryId }: Props) {
)}
</div>
)}
{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>
{/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
<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)')}
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(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>
)}
<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>
<div className="px-5 py-4">
<TagSelector
itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
/>
{/* 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>
{/* Right tag panel */}
{showTagPanel && (
<MediaTagPanel
itemKey={tagPanelItemKey ?? ''}
onHide={() => setShowTagPanel(false)}
onClose={goToSeries}
onTagsChanged={() => {
setTagRefreshKey((k) => k + 1)
setFilterRefreshKey((k) => k + 1)
fetchAssignments()
fetchSeriesEpisodeTags()
}}
externalRefreshKey={tagRefreshKey}
disabled={tagPanelDisabled}
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
readOnly={readOnly}
/>
)}
</div>
</div>
)}

View File

@@ -269,7 +269,7 @@ async function processNextJob(): Promise<boolean> {
await generateItemDescription(row.item_key)
break
case 'extract':
await extractItemText(row.item_key, jobPayload?.ocrLanguages)
await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
break
case 'translate':
await translateItemText(row.item_key, sourceLanguage || undefined)

View File

@@ -538,7 +538,7 @@ async function extractWithTesseract(
* Translation is not performed automatically — call translateItemText() separately.
* Returns { extractedText, translatedText } where translatedText is always null.
*/
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
@@ -567,7 +567,8 @@ export async function extractItemText(itemKey: string, ocrLanguagesOverride?: st
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
}
const { ocrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
const { ocrMode: configOcrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
const ocrMode = ocrModeOverride ?? configOcrMode
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
// ── Tesseract path ────────────────────────────────────────────────────────
@@ -655,6 +656,14 @@ export function updateExtractedText(itemKey: string, text: string): void {
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
}
/**
* Update the ai_description of an item.
*/
export function updateAiDescription(itemKey: string, description: string): void {
const db = getDb()
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
}
/**
* Translate text to a target language using the chat API.
* Returns null if the text is already in the target language.

View File

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

View File

@@ -106,6 +106,7 @@ function initDb(db: Database.Database): void {
migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db)
migrateAiJobs(db)
migrateLibraryPermissionsAccessLevel(db)
seedAppSettings(db)
}
@@ -318,6 +319,15 @@ function migrateLibrariesType(db: Database.Database): void {
}
}
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('access_level')) {
db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
}
}
function migrateAiJobs(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS ai_jobs (

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ export interface Library {
path: string
type: LibraryType
coverExt: string | null
accessLevel?: 'admin' | 'read' | 'write'
}
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'