Compare commits
13 Commits
c2135747b5
...
scanning-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcd592609 | ||
| 7b76e3d900 | |||
|
|
2ea02b197b | ||
|
|
8f84da7e2f | ||
|
|
625e256944 | ||
| 152bc12427 | |||
|
|
345a05e42a | ||
|
|
0de839393a | ||
|
|
0ff3ed8ac9 | ||
|
|
b2e9df8ab8 | ||
| b774cba046 | |||
|
|
5b5c3453d2 | ||
|
|
37dcb79546 |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 payload: Record<string, string> = {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -35,7 +35,7 @@ export async function PATCH(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
|
||||
|
||||
if (extractedText !== undefined) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
@@ -16,7 +16,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 db = getDb()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
@@ -14,17 +14,14 @@ interface Props {
|
||||
onAiTag?: () => Promise<void>
|
||||
showTags?: boolean
|
||||
onShowTagsChange?: (v: boolean) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange }: Props) {
|
||||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||
const showTags = showTagsProp ?? showTagsLocal
|
||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||
const [aiTagging, setAiTagging] = useState(false)
|
||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||
|
||||
// Text extraction state
|
||||
const [extractedText, setExtractedText] = 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'
|
||||
|
||||
return (
|
||||
@@ -325,6 +306,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
{!showTags && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={smallBtn}
|
||||
@@ -336,6 +318,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text display button — bottom-right, hidden when panel open */}
|
||||
@@ -367,45 +350,16 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||
{showTags && (
|
||||
<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()}
|
||||
<MediaTagPanel
|
||||
itemKey={itemKey!}
|
||||
onHide={() => setShowTags(false)}
|
||||
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 */}
|
||||
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
{/* Heading row */}
|
||||
<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
|
||||
@@ -431,7 +385,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Editable textarea */}
|
||||
<textarea
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
@@ -473,13 +426,11 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
{/* Text extraction section — only for images */}
|
||||
{isImage && (
|
||||
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
{/* Heading row */}
|
||||
<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>
|
||||
{/* AI button — forces LLM, no OCR */}
|
||||
<button
|
||||
onClick={() => callExtract('llm')}
|
||||
disabled={extracting || extractPending}
|
||||
@@ -502,16 +453,12 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OCR button row */}
|
||||
<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)',
|
||||
}}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!extracting && !extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
@@ -541,9 +488,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
/>
|
||||
</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">
|
||||
@@ -669,41 +614,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</MediaTagPanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,9 @@ import { isBrowserPlayable } from '@/lib/browser-media'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
libraryName: string
|
||||
initialPath: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type ModalState =
|
||||
@@ -21,7 +23,7 @@ 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)
|
||||
@@ -33,7 +35,9 @@ export default function MixedView({ libraryId, initialPath }: 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 [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
|
||||
const [recursiveLoading, setRecursiveLoading] = 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">
|
||||
{/* 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
|
||||
@@ -539,7 +551,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||
showTags={modalShowTags}
|
||||
onShowTagsChange={setModalShowTags}
|
||||
onAiTag={modal.itemKey ? async () => {
|
||||
readOnly={readOnly}
|
||||
onAiTag={!readOnly && modal.itemKey ? async () => {
|
||||
const res = await fetch('/api/ai-tagging', {
|
||||
method: 'POST',
|
||||
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}
|
||||
showTags={modalShowTags}
|
||||
onShowTagsChange={setModalShowTags}
|
||||
onAiTag={async () => {
|
||||
readOnly={readOnly}
|
||||
onAiTag={readOnly ? undefined : async () => {
|
||||
const res = await fetch('/api/ai-tagging', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -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 {
|
||||
@@ -16,9 +16,10 @@ interface Props {
|
||||
context?: 'mixed' | 'movies' | 'tv'
|
||||
showTags?: boolean
|
||||
onShowTagsChange?: (v: boolean) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange }: 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
|
||||
@@ -28,9 +29,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||
const showTags = showTagsProp ?? showTagsLocal
|
||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||
const [aiTagging, setAiTagging] = useState(false)
|
||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -50,22 +48,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
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'
|
||||
|
||||
return (
|
||||
@@ -100,6 +82,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
{!showTags && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={smallBtn}
|
||||
@@ -111,6 +94,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,72 +139,14 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
|
||||
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
|
||||
{showTags && (
|
||||
<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={() => 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>
|
||||
<MediaTagPanel
|
||||
itemKey={itemKey!}
|
||||
onHide={() => setShowTags(false)}
|
||||
onClose={onClose}
|
||||
onTagsChanged={onTagsChanged}
|
||||
onAiTag={readOnly ? undefined : onAiTag}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
73
src/components/tags/AssignedTagBadges.tsx
Normal file
73
src/components/tags/AssignedTagBadges.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Tag, TagCategory } from '@/types'
|
||||
|
||||
interface Props {
|
||||
itemKey: string
|
||||
refreshKey?: number
|
||||
}
|
||||
|
||||
export default function AssignedTagBadges({ itemKey, refreshKey }: Props) {
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { tags: Tag[]; categories: TagCategory[] }) => {
|
||||
setTags(data.tags ?? [])
|
||||
setCategories(data.categories ?? [])
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [itemKey, refreshKey])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[60, 80, 50].map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
className="h-5 rounded-full animate-pulse"
|
||||
style={{ width: w, backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tags.length === 0) return null
|
||||
|
||||
const catMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||
|
||||
// Group by category
|
||||
const grouped = new Map<string | null, Tag[]>()
|
||||
for (const tag of tags) {
|
||||
const key = tag.categoryId ?? null
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key)!.push(tag)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from(grouped.entries()).map(([catId, catTags]) => {
|
||||
const catName = catId ? catMap.get(catId) : null
|
||||
return catTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{catName && (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{catName}:</span>
|
||||
)}
|
||||
{tag.name}
|
||||
</span>
|
||||
))
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
src/components/tags/MediaTagPanel.tsx
Normal file
138
src/components/tags/MediaTagPanel.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import TagSelector from './TagSelector'
|
||||
|
||||
interface Props {
|
||||
itemKey: string
|
||||
onHide: () => void
|
||||
onClose: () => void
|
||||
onTagsChanged?: () => void
|
||||
externalRefreshKey?: number
|
||||
onAiTag?: () => Promise<void>
|
||||
disabled?: boolean
|
||||
disabledMessage?: string
|
||||
readOnly?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||
|
||||
export default function MediaTagPanel({
|
||||
itemKey,
|
||||
onHide,
|
||||
onClose,
|
||||
onTagsChanged,
|
||||
externalRefreshKey = 0,
|
||||
onAiTag,
|
||||
disabled,
|
||||
disabledMessage,
|
||||
readOnly,
|
||||
children,
|
||||
}: Props) {
|
||||
const [aiTagging, setAiTagging] = useState(false)
|
||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
|
||||
|
||||
const handleAiTag = async () => {
|
||||
if (!onAiTag) return
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setInternalRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Panel header — ‹ hide | ✕ close */}
|
||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={onHide}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Hide panel"
|
||||
title="Hide panel"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
{children}
|
||||
|
||||
{disabled || !itemKey ? (
|
||||
disabledMessage ? (
|
||||
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
|
||||
{disabledMessage}
|
||||
</p>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{/* Tags section heading + optional AI button */}
|
||||
<div className="flex items-center justify-between mt-4 mb-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
{onAiTag && (
|
||||
<button
|
||||
onClick={handleAiTag}
|
||||
disabled={aiTagging}
|
||||
className={`${smallBtn} disabled:opacity-50`}
|
||||
style={{
|
||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
||||
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
}}
|
||||
aria-label="AI Tag"
|
||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||
>
|
||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
||||
<TagSelector
|
||||
itemKey={itemKey}
|
||||
onTagsChanged={onTagsChanged}
|
||||
refreshKey={internalRefreshKey + externalRefreshKey}
|
||||
hideDescription
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user