Compare commits

...

10 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
29 changed files with 823 additions and 695 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger' import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -35,7 +35,7 @@ export async function PATCH(request: NextRequest) {
} }
const libraryId = itemKey.split(':')[0] const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
if (extractedText !== undefined) { if (extractedText !== undefined) {

View File

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

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs' import { enqueueJob } from '@/lib/ai-jobs'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const db = getDb() const db = getDb()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game, GameFile, GamePlatform } from '@/types' import type { Game, GameFile, GamePlatform } from '@/types'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
// Import SVG icons // Import SVG icons
@@ -30,12 +30,15 @@ interface Props {
game: Game game: Game
libraryId: string libraryId: string
onClose: () => void onClose: () => void
onPrev?: () => void
onNext?: () => void
onTagsChanged?: () => void onTagsChanged?: () => void
onCoverUploaded?: () => void onCoverUploaded?: () => void
onDeleted?: (gameId: string) => void onDeleted?: (gameId: string) => void
readOnly?: boolean
} }
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) { export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const screenshotInputRef = useRef<HTMLInputElement>(null) const screenshotInputRef = useRef<HTMLInputElement>(null)
@@ -120,6 +123,8 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return } if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
return return
} }
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return } if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return } if (confirming) { setConfirming(false); return }
@@ -135,7 +140,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length]) }, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -178,7 +183,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
{/* ── Left pane — relative container for floating controls ── */} {/* ── Left pane — relative container for floating controls ── */}
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}> <div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
{/* Scrollable card area */} {/* Scrollable card area */}
<div className="h-full overflow-y-auto flex items-start justify-center p-4"> <div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div <div
className="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)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
@@ -213,7 +218,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
</h2> </h2>
{/* Kebab menu */} {/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}> {!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button <button
onClick={() => setMenuOpen((o) => !o)} onClick={() => setMenuOpen((o) => !o)}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors" className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
@@ -265,7 +270,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
)} )}
</div> </div>
)} )}
</div> </div>}
</div> </div>
{/* AI description (read-only) */} {/* AI description (read-only) */}
@@ -497,53 +502,39 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
</button> </button>
</div> </div>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div> </div>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && ( {showTagPanel && (
<div <MediaTagPanel
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={() => setShowTagPanel(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>
<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>
{/* Tags */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector
itemKey={game.item_key!} itemKey={game.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
refreshKey={tagRefreshKey} readOnly={readOnly}
/> />
</div>
</div>
)} )}
</div> </div>

View File

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

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
interface Props { interface Props {
url: string url: string
@@ -14,17 +14,14 @@ interface Props {
onAiTag?: () => Promise<void> onAiTag?: () => Promise<void>
showTags?: boolean showTags?: boolean
onShowTagsChange?: (v: boolean) => void onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
} }
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange }: Props) { export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTagsLocal, setShowTagsLocal] = useState(false) const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal const setShowTags = onShowTagsChange ?? setShowTagsLocal
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
// Text extraction state // Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null) const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null) const [translatedText, setTranslatedText] = useState<string | null>(null)
@@ -211,22 +208,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
} }
} }
const handleAiTag = async () => {
if (!onAiTag) return
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)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
@@ -369,45 +350,16 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTags && ( {showTags && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full" itemKey={itemKey!}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onHide={() => setShowTags(false)}
onClick={(e) => e.stopPropagation()} onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
> >
{/* Panel header — hide | ✨ AI tag ✕ close */}
<div className="flex items-center justify-between p-4 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">
<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>
{/* Scrollable panel content */}
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-4 px-4 pb-4">
{/* Description section */} {/* Description section */}
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}> <div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
{/* Heading row */}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Description Description
@@ -433,7 +385,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'} {generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button> </button>
</div> </div>
{/* Editable textarea */}
<textarea <textarea
value={editedDescription} value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)} onChange={(e) => setEditedDescription(e.target.value)}
@@ -475,13 +426,11 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
{/* Text extraction section — only for images */} {/* Text extraction section — only for images */}
{isImage && ( {isImage && (
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}> <div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
{/* Heading row */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Text Extraction Text Extraction
</p> </p>
{/* AI button — forces LLM, no OCR */}
<button <button
onClick={() => callExtract('llm')} onClick={() => callExtract('llm')}
disabled={extracting || extractPending} disabled={extracting || extractPending}
@@ -504,16 +453,12 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
</button> </button>
</div> </div>
{/* OCR button row */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={() => callExtract('tesseract')} onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending} disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0" className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
style={{ style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
backgroundColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!extracting && !extractPending) { if (!extracting && !extractPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
@@ -543,9 +488,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
/> />
</div> </div>
{extractError && ( {extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
)}
{extractedText && ( {extractedText && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -671,41 +614,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
)} )}
</div> </div>
)} )}
</MediaTagPanel>
{/* Tags section */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{onAiTag && (
<button
onClick={handleAiTag}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag this image"
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={tagRefreshKey} hideDescription />
</div>
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -11,7 +11,9 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
libraryName: string
initialPath: string initialPath: string
readOnly?: boolean
} }
type ModalState = type ModalState =
@@ -21,7 +23,7 @@ type ModalState =
type TagPanelState = { entry: FileEntry; itemKey: string } | null type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, initialPath }: Props) { export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath) const [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState<DirectoryListing | null>(null) const [listing, setListing] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -33,7 +35,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([]) const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
const [recursiveLoading, setRecursiveLoading] = useState(false) const [recursiveLoading, setRecursiveLoading] = useState(false)
const [recursiveLoaded, setRecursiveLoaded] = useState(false) const [recursiveLoaded, setRecursiveLoaded] = useState(false)
@@ -339,12 +343,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm"> <nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
<a
href="/"
className="transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
Libraries
</a>
<span style={{ color: 'var(--border)' }}>/</span>
<button <button
onClick={() => loadPath('')} onClick={() => loadPath('')}
className="transition-colors" className="transition-colors"
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }} style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
> >
Root {libraryName}
</button> </button>
{breadcrumbs.map((segment, i) => { {breadcrumbs.map((segment, i) => {
const isLast = i === breadcrumbs.length - 1 const isLast = i === breadcrumbs.length - 1
@@ -539,7 +551,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags} showTags={modalShowTags}
onShowTagsChange={setModalShowTags} onShowTagsChange={setModalShowTags}
onAiTag={modal.itemKey ? async () => { readOnly={readOnly}
onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -565,7 +578,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags} showTags={modalShowTags}
onShowTagsChange={setModalShowTags} onShowTagsChange={setModalShowTags}
onAiTag={async () => { readOnly={readOnly}
onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import { useUserSettings } from '@/hooks/useUserSettings' import { useUserSettings } from '@/hooks/useUserSettings'
interface Props { interface Props {
@@ -16,9 +16,10 @@ interface Props {
context?: 'mixed' | 'movies' | 'tv' context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean showTags?: boolean
onShowTagsChange?: (v: boolean) => void onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
} }
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange }: Props) { export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const settings = useUserSettings() const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
@@ -28,9 +29,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
const [showTagsLocal, setShowTagsLocal] = useState(false) const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal const setShowTags = onShowTagsChange ?? setShowTagsLocal
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -50,22 +48,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const handleAiTag = async () => {
if (!onAiTag) return
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)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
@@ -157,72 +139,14 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */} {/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
{showTags && ( {showTags && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full" itemKey={itemKey!}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onHide={() => setShowTags(false)}
onClick={(e) => e.stopPropagation()} onClose={onClose}
> onTagsChanged={onTagsChanged}
{/* Panel header — hide | ✕ close */} onAiTag={readOnly ? undefined : onAiTag}
<div className="flex items-center justify-between p-4 flex-shrink-0"> readOnly={readOnly}
<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">
<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 */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
<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 this video"
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={tagRefreshKey} />
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Movie } from '@/types' import type { Movie } from '@/types'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
@@ -15,9 +15,10 @@ interface Props {
onTagsChanged?: () => void onTagsChanged?: () => void
onDeleted: (movieId: string) => void onDeleted: (movieId: string) => void
onMetadataRefreshed?: () => void onMetadataRefreshed?: () => void
readOnly?: boolean
} }
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) { export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
@@ -40,6 +41,8 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return } if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return } if (confirming) { setConfirming(false); return }
@@ -56,7 +59,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel]) }, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -202,7 +205,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
{/* ── Left pane — relative container for floating controls ── */} {/* ── Left pane — relative container for floating controls ── */}
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}> <div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
{/* Scrollable card area */} {/* Scrollable card area */}
<div className="h-full overflow-y-auto flex items-start justify-center p-4"> <div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div <div
className="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)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
@@ -236,7 +239,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</span> </span>
)} )}
{/* Kebab menu */} {/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}> {!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button <button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }} onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors" className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
@@ -291,7 +294,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</button> </button>
</div> </div>
)} )}
</div> </div>}
</div> </div>
{/* Rename inline input */} {/* Rename inline input */}
@@ -565,49 +568,13 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && ( {showTagPanel && (
<div <MediaTagPanel
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={() => setShowTagPanel(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>
<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>
{/* Tags */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector
itemKey={movie.item_key!} itemKey={movie.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
refreshKey={tagRefreshKey} readOnly={readOnly}
/> />
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

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

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

View File

@@ -5,6 +5,7 @@ import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import EpisodeCard from './EpisodeCard' import EpisodeCard from './EpisodeCard'
@@ -13,11 +14,12 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
readOnly?: boolean
} }
type ViewLevel = 'series' | 'seasons' | 'episodes' type ViewLevel = 'series' | 'seasons' | 'episodes'
export default function TvView({ libraryId }: Props) { export default function TvView({ libraryId, readOnly }: Props) {
const [view, setView] = useState<ViewLevel>('series') const [view, setView] = useState<ViewLevel>('series')
const [series, setSeries] = useState<TvSeries[]>([]) const [series, setSeries] = useState<TvSeries[]>([])
const [seasons, setSeasons] = useState<TvSeason[]>([]) const [seasons, setSeasons] = useState<TvSeason[]>([])
@@ -32,7 +34,11 @@ export default function TvView({ libraryId }: Props) {
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({}) const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedSeriesIndex, setSelectedSeriesIndex] = useState<number | null>(null)
const [selectedSeasonIndex, setSelectedSeasonIndex] = useState<number | null>(null)
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null) const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
@@ -93,6 +99,7 @@ export default function TvView({ libraryId }: Props) {
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags]) useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
const openSeries = (s: TvSeries) => { const openSeries = (s: TvSeries) => {
setSelectedSeriesIndex(filteredSeries.indexOf(s))
setSelectedSeries(s) setSelectedSeries(s)
setView('seasons') setView('seasons')
setLoading(true) setLoading(true)
@@ -102,16 +109,12 @@ export default function TvView({ libraryId }: Props) {
.then((data: TvSeason[]) => { .then((data: TvSeason[]) => {
setSeasons(data) setSeasons(data)
setLoading(false) setLoading(false)
// Flat series: a single synthetic season (id='.') means episodes live
// directly in the series folder — skip the seasons screen automatically.
if (data.length === 1 && data[0].id === '.') {
openSeason(data[0])
}
}) })
.catch(() => { setError('Failed to load seasons'); setLoading(false) }) .catch(() => { setError('Failed to load seasons'); setLoading(false) })
} }
const openSeason = (season: TvSeason) => { const openSeason = (season: TvSeason, index?: number) => {
setSelectedSeasonIndex(index ?? seasons.indexOf(season))
setSelectedSeason(season) setSelectedSeason(season)
setView('episodes') setView('episodes')
if (showTagPanel) { if (showTagPanel) {
@@ -143,6 +146,8 @@ export default function TvView({ libraryId }: Props) {
setView('series') setView('series')
setSelectedSeries(null) setSelectedSeries(null)
setSelectedSeason(null) setSelectedSeason(null)
setSelectedSeriesIndex(null)
setSelectedSeasonIndex(null)
setMenuOpen(false) setMenuOpen(false)
setConfirming(false) setConfirming(false)
setShowTagPanel(false) setShowTagPanel(false)
@@ -153,6 +158,7 @@ export default function TvView({ libraryId }: Props) {
const goToSeasons = () => { const goToSeasons = () => {
setView('seasons') setView('seasons')
setSelectedSeason(null) setSelectedSeason(null)
setSelectedSeasonIndex(null)
setConfirming(false) setConfirming(false)
if (showTagPanel && selectedSeries?.item_key) { if (showTagPanel && selectedSeries?.item_key) {
setTagPanelItemKey(selectedSeries.item_key) setTagPanelItemKey(selectedSeries.item_key)
@@ -180,11 +186,18 @@ export default function TvView({ libraryId }: Props) {
setRefreshingMeta(true) setRefreshingMeta(true)
setWarnRefresh(false) setWarnRefresh(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}` const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
const currentId = selectedSeries.id
fetch( fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`, `/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`,
{ method: 'POST' } { method: 'POST' }
) )
.then(() => fetchSeries()) .then(() => fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`))
.then((r) => r.json())
.then((data: TvSeries[]) => {
setSeries(data)
const updated = data.find((s) => s.id === currentId)
if (updated) setSelectedSeries(updated)
})
.finally(() => setRefreshingMeta(false)) .finally(() => setRefreshingMeta(false))
} }
@@ -386,6 +399,28 @@ export default function TvView({ libraryId }: Props) {
return true return true
}) })
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
useEffect(() => {
if (view === 'series') return
const handleArrowKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex > 0)
openSeries(filteredSeries[selectedSeriesIndex - 1])
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex > 0)
openSeason(seasons[selectedSeasonIndex - 1], selectedSeasonIndex - 1)
}
if (e.key === 'ArrowRight') {
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1)
openSeries(filteredSeries[selectedSeriesIndex + 1])
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1)
openSeason(seasons[selectedSeasonIndex + 1], selectedSeasonIndex + 1)
}
}
document.addEventListener('keydown', handleArrowKey)
return () => document.removeEventListener('keydown', handleArrowKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, selectedSeriesIndex, selectedSeasonIndex, filteredSeries, seasons])
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
if (playingEpisode && playingEpisodeIndex !== null) { if (playingEpisode && playingEpisodeIndex !== null) {
@@ -400,6 +435,7 @@ export default function TvView({ libraryId }: Props) {
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined} onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined} onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
context="tv" context="tv"
readOnly={readOnly}
/> />
) )
} }
@@ -601,7 +637,7 @@ export default function TvView({ libraryId }: Props) {
> >
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}> <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="flex-1 min-h-0 min-w-0 relative" onClick={goToSeries}>
<div className="h-full overflow-y-auto flex items-start justify-center p-4"> <div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div <div
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl" className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
@@ -878,7 +914,7 @@ export default function TvView({ libraryId }: Props) {
{seasons.map((season) => ( {seasons.map((season) => (
<button <button
key={season.id} key={season.id}
onClick={() => openSeason(season)} onClick={() => openSeason(season, seasons.indexOf(season))}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2" className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
@@ -966,7 +1002,7 @@ export default function TvView({ libraryId }: Props) {
{/* Floating controls — tag + close */} {/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}> <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 && ( {view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
<button <button
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }} onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
className={smallBtn} className={smallBtn}
@@ -990,63 +1026,61 @@ export default function TvView({ libraryId }: Props) {
</button> </button>
</div> </div>
{/* Prev — series in seasons view, season in episodes view */}
{(view === 'seasons'
? selectedSeriesIndex !== null && selectedSeriesIndex > 0
: selectedSeasonIndex !== null && selectedSeasonIndex > 0) && (
<button
onClick={(e) => {
e.stopPropagation()
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! - 1])
else openSeason(seasons[selectedSeasonIndex! - 1], selectedSeasonIndex! - 1)
}}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{/* Next — series in seasons view, season in episodes view */}
{(view === 'seasons'
? selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1
: selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1) && (
<button
onClick={(e) => {
e.stopPropagation()
if (view === 'seasons') openSeries(filteredSeries[selectedSeriesIndex! + 1])
else openSeason(seasons[selectedSeasonIndex! + 1], selectedSeasonIndex! + 1)
}}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div> </div>
{/* Right tag panel */} {/* Right tag panel */}
{showTagPanel && ( {showTagPanel && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full" itemKey={tagPanelItemKey ?? ''}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onHide={() => setShowTagPanel(false)}
onClick={(e) => e.stopPropagation()} onClose={goToSeries}
>
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={() => setShowTagPanel(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>
<button
onClick={goToSeries}
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 className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
{tagPanelDisabled ? (
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
Seasons cannot be tagged. Select an episode to tag it.
</p>
) : tagPanelItemKey ? (
<>
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector
itemKey={tagPanelItemKey}
onTagsChanged={() => { onTagsChanged={() => {
setTagRefreshKey((k) => k + 1) setTagRefreshKey((k) => k + 1)
setFilterRefreshKey((k) => k + 1) setFilterRefreshKey((k) => k + 1)
fetchAssignments() fetchAssignments()
fetchSeriesEpisodeTags() fetchSeriesEpisodeTags()
}} }}
refreshKey={tagRefreshKey} externalRefreshKey={tagRefreshKey}
disabled={tagPanelDisabled}
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
readOnly={readOnly}
/> />
</>
) : null}
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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