From 768c49ef006b9ebfde432d7f62adbef7a6f8d28b Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:33:03 -0400 Subject: [PATCH] add more management capabilities --- .gitignore | 3 +- src/app/api/browse/route.ts | 62 ++++- src/app/api/games/route.ts | 50 +++- src/app/api/metadata/route.ts | 61 +++++ src/app/api/rename/route.ts | 200 +++++++++++++++ src/app/api/tv/route.ts | 34 +++ src/components/games/GameDetailModal.tsx | 146 ++++++++++- src/components/games/GamesView.tsx | 5 + src/components/mixed/MixedView.tsx | 181 ++++++++++++- src/components/movies/MovieDetailModal.tsx | 279 +++++++++++++++++++-- src/components/tv/EpisodeCard.tsx | 149 ++++++++++- src/components/tv/TvView.tsx | 271 +++++++++++++++++++- src/lib/movies.ts | 1 + src/lib/scanner.ts | 30 ++- src/lib/tv.ts | 1 + src/types/index.ts | 2 + 16 files changed, 1420 insertions(+), 55 deletions(-) create mode 100644 src/app/api/metadata/route.ts create mode 100644 src/app/api/rename/route.ts diff --git a/.gitignore b/.gitignore index 46d3430..8933a9f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ medialore.db medialore.db-shm medialore.db-wal tsconfig.tsbuildinfo -.session_secret \ No newline at end of file +.session_secret +.vscode/ \ No newline at end of file diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 0aaf717..f39d863 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -1,7 +1,10 @@ +import fs from 'fs' import { NextRequest, NextResponse } from 'next/server' -import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryAccess, requireAdmin } from '@/lib/auth' +import { removeAllAssignmentsForItem } from '@/lib/tags' +import { getDb } from '@/lib/db' export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl @@ -30,3 +33,58 @@ export async function GET(request: NextRequest) { : scanDirectory(root, libraryId, subpath) return NextResponse.json(listing) } + +export async function DELETE(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const itemPath = searchParams.get('path') + + if (!libraryId || !itemPath) { + return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + if (library.type !== 'mixed') { + return NextResponse.json({ error: 'Library is not a mixed library' }, { status: 400 }) + } + + const root = resolveLibraryRoot(library) + let absPath: string + try { + absPath = resolveAndJail(root, itemPath) + } catch { + return NextResponse.json({ error: 'Invalid path' }, { status: 400 }) + } + + try { + const stat = fs.statSync(absPath) + if (stat.isDirectory()) { + fs.rmSync(absPath, { recursive: true, force: true }) + } else { + fs.unlinkSync(absPath) + } + } catch { + return NextResponse.json({ error: 'Failed to delete' }, { status: 500 }) + } + + const db = getDb() + const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(itemPath)}` + removeAllAssignmentsForItem(itemKey) + db.prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey) + + // For directories, also clean up children + const prefix = `${libraryId}:mixed_file:${encodeURIComponent(itemPath + '/')}` + const children = db.prepare('SELECT item_key FROM media_items WHERE item_key LIKE ?').all(`${prefix}%`) as { item_key: string }[] + for (const child of children) { + removeAllAssignmentsForItem(child.item_key) + } + db.prepare('DELETE FROM media_items WHERE item_key LIKE ?').run(`${prefix}%`) + + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/games/route.ts b/src/app/api/games/route.ts index 9098f78..4d822b9 100644 --- a/src/app/api/games/route.ts +++ b/src/app/api/games/route.ts @@ -1,7 +1,10 @@ +import fs from 'fs' import { NextRequest, NextResponse } from 'next/server' -import { getLibrary } from '@/lib/libraries' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { gamesFromDb } from '@/lib/games' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryAccess, requireAdmin } from '@/lib/auth' +import { removeAllAssignmentsForItem } from '@/lib/tags' +import { getDb } from '@/lib/db' export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl @@ -24,3 +27,46 @@ export async function GET(request: NextRequest) { return NextResponse.json(gamesFromDb(libraryId)) } + +export async function DELETE(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const gameId = searchParams.get('gameId') + + if (!libraryId || !gameId) { + return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + if (library.type !== 'games') { + return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 }) + } + + const root = resolveLibraryRoot(library) + const dirName = decodeURIComponent(gameId) + + let gameDir: string + try { + gameDir = resolveAndJail(root, dirName) + } catch { + return NextResponse.json({ error: 'Invalid game path' }, { status: 400 }) + } + + try { + fs.rmSync(gameDir, { recursive: true, force: true }) + } catch { + return NextResponse.json({ error: 'Failed to delete game directory' }, { status: 500 }) + } + + const itemKey = `${libraryId}:game:${gameId}` + removeAllAssignmentsForItem(itemKey) + getDb().prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey) + + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/metadata/route.ts b/src/app/api/metadata/route.ts new file mode 100644 index 0000000..cb644a6 --- /dev/null +++ b/src/app/api/metadata/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { getDb } from '@/lib/db' + +export async function PATCH(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const body = await request.json() + const { itemKey, title, year, plot, genres } = body as { + itemKey: string + title?: string + year?: number | null + plot?: string | null + genres?: string[] + } + + if (!itemKey) { + return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 }) + } + + const db = getDb() + const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined + if (!row) { + return NextResponse.json({ error: 'Item not found' }, { status: 404 }) + } + + const sets: string[] = [] + const params: Record = { item_key: itemKey } + + if (title !== undefined) { + sets.push('title = @title') + params.title = title + } + if (year !== undefined) { + sets.push('year = @year') + params.year = year + } + if (plot !== undefined) { + sets.push('plot = @plot') + params.plot = plot + } + if (genres !== undefined) { + sets.push('genres = @genres') + params.genres = JSON.stringify(genres) + } + + // Always mark as manually edited in the metadata blob + const existingMeta = row.metadata ? JSON.parse(row.metadata) : {} + existingMeta.manuallyEdited = true + sets.push('metadata = @metadata') + params.metadata = JSON.stringify(existingMeta) + + if (sets.length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) + } + + db.prepare(`UPDATE media_items SET ${sets.join(', ')} WHERE item_key = @item_key`).run(params) + + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/rename/route.ts b/src/app/api/rename/route.ts new file mode 100644 index 0000000..5bce4c0 --- /dev/null +++ b/src/app/api/rename/route.ts @@ -0,0 +1,200 @@ +import fs from 'fs' +import path from 'path' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' +import { requireAdmin } from '@/lib/auth' +import { getDb } from '@/lib/db' +import { reKeyMediaItem } from '@/lib/tags' + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const body = await request.json() + const { libraryId, oldPath, newName, itemType } = body as { + libraryId: string + oldPath: string + newName: string + itemType: string + } + + if (!libraryId || !oldPath || !newName || !itemType) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) + } + + // Validate newName + if (/[/\\]/.test(newName) || newName === '.' || newName === '..' || newName.startsWith('.')) { + return NextResponse.json({ error: 'Invalid name' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + + const root = resolveLibraryRoot(library) + + let oldAbsPath: string + try { + oldAbsPath = resolveAndJail(root, oldPath) + } catch { + return NextResponse.json({ error: 'Invalid path' }, { status: 400 }) + } + + // Compute new path by replacing the last segment + const parentDir = path.dirname(oldPath) + const newPath = parentDir === '.' ? newName : `${parentDir}/${newName}` + + let newAbsPath: string + try { + newAbsPath = resolveAndJail(root, newPath) + } catch { + return NextResponse.json({ error: 'Invalid new path' }, { status: 400 }) + } + + // Collision check + if (fs.existsSync(newAbsPath)) { + return NextResponse.json({ error: 'A file or folder with that name already exists' }, { status: 409 }) + } + + // Perform filesystem rename + try { + fs.renameSync(oldAbsPath, newAbsPath) + } catch { + return NextResponse.json({ error: 'Failed to rename' }, { status: 500 }) + } + + // Update database records + const db = getDb() + const enc = encodeURIComponent + const oldEnc = enc(oldPath) + const newEnc = enc(newPath) + + try { + db.transaction(() => { + switch (itemType) { + case 'movie': { + const oldKey = `${libraryId}:movie:${oldEnc}` + const newKey = `${libraryId}:movie:${newEnc}` + db.prepare('UPDATE media_items SET item_key = ?, file_path = replace(file_path, ?, ?) WHERE item_key = ?') + .run(newKey, oldPath, newPath, oldKey) + reKeyMediaItem(oldKey, newKey) + break + } + + case 'tv_series': { + const oldKey = `${libraryId}:tv_series:${oldEnc}` + const newKey = `${libraryId}:tv_series:${newEnc}` + // Update series + db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey) + reKeyMediaItem(oldKey, newKey) + + // Update seasons: item_key contains series id + const seasonRows = db.prepare( + "SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_season'" + ).all(`${libraryId}:tv_season:${oldEnc}:%`) as { item_key: string }[] + + for (const row of seasonRows) { + const newSeasonKey = row.item_key.replace(`:tv_season:${oldEnc}:`, `:tv_season:${newEnc}:`) + db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?') + .run(newSeasonKey, newKey, row.item_key) + reKeyMediaItem(row.item_key, newSeasonKey) + } + + // Update episodes: item_key and file_path contain series path + const epRows = db.prepare( + "SELECT item_key, file_path FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_episode'" + ).all(`${libraryId}:tv_episode:${oldEnc}:%`) as { item_key: string; file_path: string | null }[] + + for (const row of epRows) { + const newEpKey = row.item_key.replace(`:tv_episode:${oldEnc}:`, `:tv_episode:${newEnc}:`) + // Find new parent_key from the episode's season portion + const newParentKey = row.item_key + .replace(`:tv_episode:${oldEnc}:`, `:tv_season:${newEnc}:`) + .split(':') + .slice(0, -1) // Remove episode id portion — parent is season + // Actually, parent_key is the season key. We need to reconstruct it. + // Episode key format: libraryId:tv_episode:seriesId:seasonId:episodeId + // Season key format: libraryId:tv_season:seriesId:seasonId + const parts = newEpKey.split(':') + // parts: [libraryId, 'tv_episode', seriesEnc, seasonEnc, episodeEnc] + const seasonKey = `${parts[0]}:tv_season:${parts[2]}:${parts[3]}` + const newFilePath = row.file_path ? row.file_path.replace(oldPath, newPath) : null + db.prepare('UPDATE media_items SET item_key = ?, parent_key = ?, file_path = ? WHERE item_key = ?') + .run(newEpKey, seasonKey, newFilePath, row.item_key) + reKeyMediaItem(row.item_key, newEpKey) + } + break + } + + case 'tv_episode': { + const oldKey = `${libraryId}:tv_episode:${oldEnc}` + const newKey = `${libraryId}:tv_episode:${newEnc}` + db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?') + .run(newKey, newPath, oldKey) + reKeyMediaItem(oldKey, newKey) + break + } + + case 'game': { + const oldKey = `${libraryId}:game:${oldEnc}` + const newKey = `${libraryId}:game:${newEnc}` + db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey) + reKeyMediaItem(oldKey, newKey) + break + } + + case 'game_series': { + const oldKey = `${libraryId}:game_series:${oldEnc}` + const newKey = `${libraryId}:game_series:${newEnc}` + db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey) + reKeyMediaItem(oldKey, newKey) + + // Update child games + const gameRows = db.prepare( + "SELECT item_key FROM media_items WHERE parent_key = ? AND item_type = 'game'" + ).all(oldKey) as { item_key: string }[] + + for (const row of gameRows) { + const newGameKey = row.item_key.replace(`:game:${oldEnc}`, `:game:${newEnc}`) + db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?') + .run(newGameKey, newKey, row.item_key) + reKeyMediaItem(row.item_key, newGameKey) + } + break + } + + case 'mixed_file': { + const oldKey = `${libraryId}:mixed_file:${oldEnc}` + const newKey = `${libraryId}:mixed_file:${newEnc}` + db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?') + .run(newKey, newPath, oldKey) + reKeyMediaItem(oldKey, newKey) + + // If directory, update all children + const childRows = db.prepare( + "SELECT item_key, file_path FROM media_items WHERE item_key LIKE ?" + ).all(`${libraryId}:mixed_file:${enc(oldPath + '/')}%`) as { item_key: string; file_path: string | null }[] + + for (const row of childRows) { + const newChildKey = row.item_key.replace( + `mixed_file:${enc(oldPath + '/')}`, + `mixed_file:${enc(newPath + '/')}` + ) + const newChildPath = row.file_path ? row.file_path.replace(oldPath + '/', newPath + '/') : null + db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?') + .run(newChildKey, newChildPath, row.item_key) + reKeyMediaItem(row.item_key, newChildKey) + } + break + } + } + })() + } catch (err) { + // Attempt to rollback filesystem rename on DB failure + try { fs.renameSync(newAbsPath, oldAbsPath) } catch { /* best effort */ } + return NextResponse.json({ error: 'Database update failed' }, { status: 500 }) + } + + return NextResponse.json({ newName, newPath }) +} diff --git a/src/app/api/tv/route.ts b/src/app/api/tv/route.ts index b063d0b..5bcc769 100644 --- a/src/app/api/tv/route.ts +++ b/src/app/api/tv/route.ts @@ -5,6 +5,7 @@ import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv' import { removeAllAssignmentsForItem } from '@/lib/tags' import { requireLibraryAccess, requireAdmin } from '@/lib/auth' +import { getDb } from '@/lib/db' export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl @@ -45,6 +46,7 @@ export async function DELETE(request: NextRequest) { const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const seriesId = searchParams.get('seriesId') + const episodeKey = searchParams.get('episodeKey') if (!libraryId || !seriesId) { return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 }) @@ -59,6 +61,38 @@ export async function DELETE(request: NextRequest) { } const root = resolveLibraryRoot(library) + + // Episode-level delete + if (episodeKey) { + const db = getDb() + const row = db.prepare('SELECT file_path FROM media_items WHERE item_key = ?').get(episodeKey) as { file_path: string | null } | undefined + if (!row?.file_path) { + return NextResponse.json({ error: 'Episode not found' }, { status: 404 }) + } + + let episodePath: string + try { + episodePath = resolveAndJail(root, row.file_path) + } catch { + return NextResponse.json({ error: 'Invalid episode path' }, { status: 400 }) + } + + try { + fs.unlinkSync(episodePath) + // Also remove sidecar NFO if it exists + const nfoPath = episodePath.replace(path.extname(episodePath), '.nfo') + if (fs.existsSync(nfoPath)) fs.unlinkSync(nfoPath) + } catch { + return NextResponse.json({ error: 'Failed to delete episode file' }, { status: 500 }) + } + + removeAllAssignmentsForItem(episodeKey) + db.prepare('DELETE FROM media_items WHERE item_key = ?').run(episodeKey) + + return new NextResponse(null, { status: 204 }) + } + + // Series-level delete const dirName = decodeURIComponent(seriesId) let seriesDir: string diff --git a/src/components/games/GameDetailModal.tsx b/src/components/games/GameDetailModal.tsx index 8d41fcb..ba0c07a 100644 --- a/src/components/games/GameDetailModal.tsx +++ b/src/components/games/GameDetailModal.tsx @@ -10,18 +10,27 @@ interface Props { onClose: () => void onTagsChanged?: () => void onCoverUploaded?: () => void + onDeleted?: (gameId: string) => void } -export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded }: Props) { +export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) { const overlayRef = useRef(null) const menuRef = useRef(null) const [menuOpen, setMenuOpen] = useState(false) const [editingImages, setEditingImages] = useState(false) + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + const [renaming, setRenaming] = useState(false) + const [renameName, setRenameName] = useState('') + const [renameError, setRenameError] = useState(null) + const [renameSaving, setRenameSaving] = useState(false) useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (menuOpen) { setMenuOpen(false); return } + if (confirming) { setConfirming(false); return } + if (renaming) { setRenaming(false); return } if (editingImages) { setEditingImages(false); return } onClose() } @@ -32,7 +41,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } - }, [onClose, menuOpen, editingImages]) + }, [onClose, menuOpen, editingImages, confirming, renaming]) // Close menu on outside click useEffect(() => { @@ -130,11 +139,144 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange > Edit images + + {onDeleted && ( + + )} )} + {/* Rename inline input */} + {renaming && ( +
+
+ setRenameName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const trimmed = renameName.trim() + if (!trimmed) return + setRenameSaving(true) + setRenameError(null) + fetch('/api/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }), + }) + .then(async (res) => { + if (res.status === 409) { setRenameError((await res.json()).error); return } + if (!res.ok) throw new Error() + setRenaming(false) + onCoverUploaded?.() // triggers refetch + }) + .catch(() => setRenameError('Rename failed')) + .finally(() => setRenameSaving(false)) + } + if (e.key === 'Escape') setRenaming(false) + }} + className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + autoFocus + /> + + +
+ {renameError &&

{renameError}

} +
+ )} + + {/* Delete confirmation banner */} + {confirming && ( +
+

+ Permanently delete this game and all its files? +

+ + +
+ )} + {/* Tags */} diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index a3150a2..62d8a7b 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -183,6 +183,11 @@ export default function GamesView({ libraryId }: Props) { onClose={() => setSelected(null)} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onCoverUploaded={() => fetchGames(true)} + onDeleted={() => { + setSelected(null) + fetchGames() + fetchAssignments() + }} /> )} diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 7f6e9b6..f40fc94 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -316,7 +316,39 @@ export default function MixedView({ libraryId, initialPath }: Props) { )} {filteredEntries.map((entry) => ( - + { + const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) + fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' }) + .then(() => { + if (filtersActive) { + setRecursiveEntries((prev) => prev.filter((r) => r.name !== e.name)) + } else { + setListing((prev) => prev ? { ...prev, entries: prev.entries.filter((r) => r.name !== e.name) } : prev) + } + }) + }} + onRename={async (e, newName) => { + const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) + const res = await fetch('/api/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ libraryId, oldPath: rel, newName, itemType: 'mixed_file' }), + }) + if (!res.ok) return false + // Refresh the listing + if (filtersActive) { + fetchRecursive() + } else { + loadPath(currentPath) + } + return true + }} + /> ))} )} @@ -392,11 +424,28 @@ export default function MixedView({ libraryId, initialPath }: Props) { ) } -function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void }) { +function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise }) { type ImgState = 'loading' | 'loaded' | 'error' const [imgState, setImgState] = useState( entry.thumbnailUrl ? 'loading' : 'error' ) + const menuRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + const [entryRenaming, setEntryRenaming] = useState(false) + const [entryRenameName, setEntryRenameName] = useState('') + const [entryRenameError, setEntryRenameError] = useState(null) + const [entryRenameSaving, setEntryRenameSaving] = useState(false) + + useEffect(() => { + if (!menuOpen) return + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [menuOpen]) // Reset image state when the entry changes (e.g. navigating to a new folder) const prevUrl = useRef(entry.thumbnailUrl) if (prevUrl.current !== entry.thumbnailUrl) { @@ -497,6 +546,134 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil > 🏷 + + {/* Kebab menu — top-right, shown on hover */} + {(onDelete || onRename) && ( +
+ + {menuOpen && ( +
+ {onRename && ( + + )} + {onDelete && ( + + )} +
+ )} +
+ )} + + {/* Delete confirmation overlay */} + {confirming && ( +
e.stopPropagation()} + > +

Delete?

+ + +
+ )} + + {/* Rename overlay */} + {entryRenaming && ( +
e.stopPropagation()} + > + setEntryRenameName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && onRename) { + const trimmed = entryRenameName.trim() + if (!trimmed) return + setEntryRenameSaving(true) + setEntryRenameError(null) + onRename(entry, trimmed).then((ok) => { + if (ok) setEntryRenaming(false) + else setEntryRenameError('Name already exists') + }).finally(() => setEntryRenameSaving(false)) + } + if (e.key === 'Escape') setEntryRenaming(false) + }} + className="w-full px-2 py-1 rounded text-xs" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + autoFocus + /> +
+ + +
+ {entryRenameError &&

{entryRenameError}

} +
+ )} ) } diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx index 7baa477..4dd3082 100644 --- a/src/components/movies/MovieDetailModal.tsx +++ b/src/components/movies/MovieDetailModal.tsx @@ -24,12 +24,23 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) const [refreshing, setRefreshing] = useState(false) + const [editing, setEditing] = useState(false) + const [saving, setSaving] = useState(false) + const [editForm, setEditForm] = useState({ title: '', year: '', plot: '', genres: '' }) + const [warnRefresh, setWarnRefresh] = useState(false) + const [renaming, setRenaming] = useState(false) + const [renameName, setRenameName] = useState('') + const [renameError, setRenameError] = useState(null) + const [renameSaving, setRenameSaving] = useState(false) useEffect(() => { const handleKey = (e: KeyboardEvent) => { 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 } onClose() } } @@ -39,7 +50,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } - }, [onClose, menuOpen, confirming]) + }, [onClose, menuOpen, confirming, editing, warnRefresh, renaming]) // Close menu on outside click useEffect(() => { @@ -68,9 +79,9 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on .catch(() => setDeleting(false)) } - const handleRefreshMetadata = () => { + const doRefreshMetadata = () => { setRefreshing(true) - setMenuOpen(false) + setWarnRefresh(false) const itemKey = `${libraryId}:movie:${movie.id}` fetch( `/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`, @@ -80,6 +91,82 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on .finally(() => setRefreshing(false)) } + const handleRefreshMetadata = () => { + setMenuOpen(false) + if (movie.manuallyEdited) { + setWarnRefresh(true) + } else { + doRefreshMetadata() + } + } + + const handleStartEditing = () => { + setMenuOpen(false) + setEditForm({ + title: movie.title, + year: movie.year?.toString() ?? '', + plot: movie.plot ?? '', + genres: movie.genres.join(', '), + }) + setEditing(true) + } + + const handleSaveMetadata = () => { + setSaving(true) + const genres = editForm.genres.split(',').map((g) => g.trim()).filter(Boolean) + const yearNum = editForm.year ? parseInt(editForm.year, 10) : null + fetch('/api/metadata', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + itemKey: movie.item_key, + title: editForm.title, + year: isNaN(yearNum as number) ? null : yearNum, + plot: editForm.plot || null, + genres, + }), + }) + .then(() => { setEditing(false); onMetadataRefreshed?.() }) + .finally(() => setSaving(false)) + } + + const handleStartRename = () => { + setMenuOpen(false) + // movie.id is the encoded folder name + setRenameName(decodeURIComponent(movie.id)) + setRenameError(null) + setRenaming(true) + } + + const handleRename = () => { + const trimmed = renameName.trim() + if (!trimmed) return + setRenameSaving(true) + setRenameError(null) + fetch('/api/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + libraryId, + oldPath: decodeURIComponent(movie.id), + newName: trimmed, + itemType: 'movie', + }), + }) + .then(async (res) => { + if (res.status === 409) { + const data = await res.json() + setRenameError(data.error) + return + } + if (!res.ok) throw new Error() + setRenaming(false) + onMetadataRefreshed?.() + }) + .catch(() => setRenameError('Rename failed')) + .finally(() => setRenameSaving(false)) + } + if (playing) { return ( {refreshing ? 'Refreshing…' : 'Refresh metadata'} + + + + + {renameError &&

{renameError}

} )} - {movie.plot && ( -

- {movie.plot} -

+ {editing ? ( +
+
+ + setEditForm((f) => ({ ...f, title: e.target.value }))} + className="w-full px-3 py-1.5 rounded-lg text-sm" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + autoFocus + /> +
+
+ + setEditForm((f) => ({ ...f, year: e.target.value }))} + className="w-full px-3 py-1.5 rounded-lg text-sm" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + /> +
+
+ +