add more management capabilities
This commit is contained in:
@@ -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<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(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<string | null>(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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setRenameName(decodeURIComponent(game.id))
|
||||
setRenameError(null)
|
||||
setRenaming(true)
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Rename folder
|
||||
</button>
|
||||
{onDeleted && (
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: '#fca5a5' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Delete game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rename inline input */}
|
||||
{renaming && (
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const trimmed = renameName.trim()
|
||||
if (!trimmed) return
|
||||
setRenameSaving(true)
|
||||
setRenameError(null)
|
||||
fetch('/api/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
||||
if (!res.ok) throw new Error()
|
||||
setRenaming(false)
|
||||
onCoverUploaded?.() // triggers refetch
|
||||
})
|
||||
.catch(() => setRenameError('Rename failed'))
|
||||
.finally(() => setRenameSaving(false))
|
||||
}
|
||||
if (e.key === 'Escape') setRenaming(false)
|
||||
}}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => setRenaming(false)}
|
||||
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const trimmed = renameName.trim()
|
||||
if (!trimmed) return
|
||||
setRenameSaving(true)
|
||||
setRenameError(null)
|
||||
fetch('/api/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
||||
if (!res.ok) throw new Error()
|
||||
setRenaming(false)
|
||||
onCoverUploaded?.()
|
||||
})
|
||||
.catch(() => setRenameError('Rename failed'))
|
||||
.finally(() => setRenameSaving(false))
|
||||
}}
|
||||
disabled={renameSaving}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{renameSaving ? '…' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation banner */}
|
||||
{confirming && (
|
||||
<div
|
||||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||||
>
|
||||
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||||
Permanently delete this game and all its files?
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleting(true)
|
||||
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
|
||||
.then(() => onDeleted!(game.id))
|
||||
.catch(() => setDeleting(false))
|
||||
}}
|
||||
disabled={deleting}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
|
||||
|
||||
{/* Tags */}
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -316,7 +316,39 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
</button>
|
||||
)}
|
||||
{filteredEntries.map((entry) => (
|
||||
<EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} />
|
||||
<EntryTile
|
||||
key={entry.name}
|
||||
entry={entry}
|
||||
onOpen={handleEntry}
|
||||
onTag={handleTagEntry}
|
||||
onDelete={(e) => {
|
||||
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
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -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<boolean> }) {
|
||||
type ImgState = 'loading' | 'loaded' | 'error'
|
||||
const [imgState, setImgState] = useState<ImgState>(
|
||||
entry.thumbnailUrl ? 'loading' : 'error'
|
||||
)
|
||||
const menuRef = useRef<HTMLDivElement>(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<string | null>(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
|
||||
>
|
||||
🏷
|
||||
</button>
|
||||
|
||||
{/* Kebab menu — top-right, shown on hover */}
|
||||
{(onDelete || onRename) && (
|
||||
<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) }}
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||
aria-label="More options"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
setEntryRenameName(entry.name)
|
||||
setEntryRenameError(null)
|
||||
setEntryRenaming(true)
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: '#fca5a5' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation overlay */}
|
||||
{confirming && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 px-2 py-2 text-xs"
|
||||
style={{ backgroundColor: 'rgba(127,29,29,0.9)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="flex-1" style={{ color: '#fca5a5' }}>Delete?</p>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="px-2 py-0.5 rounded transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDeleting(true); onDelete!(entry) }}
|
||||
disabled={deleting}
|
||||
className="px-2 py-0.5 rounded transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||
>
|
||||
{deleting ? '…' : 'Yes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename overlay */}
|
||||
{entryRenaming && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-1 px-2 py-2 text-xs"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={entryRenameName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => setEntryRenaming(false)} className="px-2 py-0.5 rounded" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}>Cancel</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!onRename) return
|
||||
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))
|
||||
}}
|
||||
disabled={entryRenameSaving}
|
||||
className="px-2 py-0.5 rounded disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{entryRenameSaving ? '…' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
{entryRenameError && <p style={{ color: '#fca5a5' }}>{entryRenameError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<VideoPlayerModal
|
||||
@@ -199,6 +286,24 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartEditing}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Edit metadata
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartRename}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Rename folder
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
@@ -213,31 +318,155 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{movie.rating !== null && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
★ {movie.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{movie.runtime !== null && (
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.runtime} min
|
||||
</span>
|
||||
)}
|
||||
{movie.genres.map((g) => (
|
||||
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
{/* Rename inline input */}
|
||||
{renaming && (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => setRenaming(false)}
|
||||
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRename}
|
||||
disabled={renameSaving}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{renameSaving ? '…' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{movie.plot && (
|
||||
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.plot}
|
||||
</p>
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.year}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editForm.plot}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.genres}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveMetadata}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Meta row */}
|
||||
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{movie.rating !== null && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
★ {movie.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{movie.runtime !== null && (
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.runtime} min
|
||||
</span>
|
||||
)}
|
||||
{movie.genres.map((g) => (
|
||||
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{movie.plot && (
|
||||
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.plot}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* NFO refresh warning */}
|
||||
{warnRefresh && (
|
||||
<div
|
||||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
|
||||
>
|
||||
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
|
||||
Refreshing from NFO will overwrite your manual edits.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setWarnRefresh(false)}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={doRefreshMetadata}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
||||
>
|
||||
Overwrite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation banner */}
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { TvEpisode } from '@/types'
|
||||
|
||||
interface Props {
|
||||
episode: TvEpisode
|
||||
onClick: () => void
|
||||
onTag?: () => void
|
||||
onDelete?: () => void
|
||||
onRename?: (newName: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export default function EpisodeCard({ episode, onClick, onTag }: Props) {
|
||||
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename }: Props) {
|
||||
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [menuOpen, setMenuOpen] = 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<string | null>(null)
|
||||
const [renameSaving, setRenameSaving] = 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])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,7 +78,134 @@ export default function EpisodeCard({ episode, onClick, onTag }: Props) {
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
{/* Kebab menu */}
|
||||
{onDelete && (
|
||||
<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) }}
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||
aria-label="More options"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
// Extract filename from videoPath (last segment, without extension for user friendliness)
|
||||
const fileName = episode.videoPath.split('/').pop() ?? ''
|
||||
setRenameName(fileName)
|
||||
setRenameError(null)
|
||||
setRenaming(true)
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Rename file
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: '#fca5a5' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Delete episode
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation overlay */}
|
||||
{confirming && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 px-2 py-2 text-xs"
|
||||
style={{ backgroundColor: 'rgba(127,29,29,0.9)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="flex-1" style={{ color: '#fca5a5' }}>Delete?</p>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="px-2 py-0.5 rounded transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDeleting(true); onDelete!() }}
|
||||
disabled={deleting}
|
||||
className="px-2 py-0.5 rounded transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Yes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename overlay */}
|
||||
{renaming && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-1 px-2 py-2 text-xs"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const trimmed = renameName.trim()
|
||||
if (!trimmed || !onRename) return
|
||||
setRenameSaving(true)
|
||||
setRenameError(null)
|
||||
onRename(trimmed).then((ok) => {
|
||||
if (ok) setRenaming(false)
|
||||
else setRenameError('Rename failed or name already exists')
|
||||
}).finally(() => setRenameSaving(false))
|
||||
}
|
||||
if (e.key === 'Escape') setRenaming(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
|
||||
/>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => setRenaming(false)} className="px-2 py-0.5 rounded" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}>Cancel</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const trimmed = renameName.trim()
|
||||
if (!trimmed || !onRename) return
|
||||
setRenameSaving(true)
|
||||
setRenameError(null)
|
||||
onRename(trimmed).then((ok) => {
|
||||
if (ok) setRenaming(false)
|
||||
else setRenameError('Rename failed or name already exists')
|
||||
}).finally(() => setRenameSaving(false))
|
||||
}}
|
||||
disabled={renameSaving}
|
||||
className="px-2 py-0.5 rounded disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{renameSaving ? '…' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
{renameError && <p style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-2">
|
||||
{epLabel && (
|
||||
<p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}>
|
||||
|
||||
@@ -37,6 +37,14 @@ export default function TvView({ libraryId }: Props) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [refreshingMeta, setRefreshingMeta] = useState(false)
|
||||
const [editingMeta, setEditingMeta] = useState(false)
|
||||
const [savingMeta, setSavingMeta] = 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<string | null>(null)
|
||||
const [renameSaving, setRenameSaving] = useState(false)
|
||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||
@@ -151,10 +159,10 @@ export default function TvView({ libraryId }: Props) {
|
||||
.catch(() => setDeleting(false))
|
||||
}
|
||||
|
||||
const handleRefreshSeriesMetadata = () => {
|
||||
const doRefreshSeriesMetadata = () => {
|
||||
if (!selectedSeries) return
|
||||
setRefreshingMeta(true)
|
||||
setMenuOpen(false)
|
||||
setWarnRefresh(false)
|
||||
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
|
||||
fetch(
|
||||
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
|
||||
@@ -164,6 +172,85 @@ export default function TvView({ libraryId }: Props) {
|
||||
.finally(() => setRefreshingMeta(false))
|
||||
}
|
||||
|
||||
const handleRefreshSeriesMetadata = () => {
|
||||
setMenuOpen(false)
|
||||
if (selectedSeries?.manuallyEdited) {
|
||||
setWarnRefresh(true)
|
||||
} else {
|
||||
doRefreshSeriesMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEditingMeta = () => {
|
||||
if (!selectedSeries) return
|
||||
setMenuOpen(false)
|
||||
setEditForm({
|
||||
title: selectedSeries.title,
|
||||
year: selectedSeries.year?.toString() ?? '',
|
||||
plot: selectedSeries.plot ?? '',
|
||||
genres: selectedSeries.genres.join(', '),
|
||||
})
|
||||
setEditingMeta(true)
|
||||
}
|
||||
|
||||
const handleSaveSeriesMetadata = () => {
|
||||
if (!selectedSeries) return
|
||||
setSavingMeta(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: selectedSeries.item_key,
|
||||
title: editForm.title,
|
||||
year: isNaN(yearNum as number) ? null : yearNum,
|
||||
plot: editForm.plot || null,
|
||||
genres,
|
||||
}),
|
||||
})
|
||||
.then(() => { setEditingMeta(false); fetchSeries() })
|
||||
.finally(() => setSavingMeta(false))
|
||||
}
|
||||
|
||||
const handleStartRename = () => {
|
||||
if (!selectedSeries) return
|
||||
setMenuOpen(false)
|
||||
setRenameName(decodeURIComponent(selectedSeries.id))
|
||||
setRenameError(null)
|
||||
setRenaming(true)
|
||||
}
|
||||
|
||||
const handleRename = () => {
|
||||
if (!selectedSeries) return
|
||||
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(selectedSeries.id),
|
||||
newName: trimmed,
|
||||
itemType: 'tv_series',
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 409) {
|
||||
const data = await res.json()
|
||||
setRenameError(data.error)
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error()
|
||||
setRenaming(false)
|
||||
fetchSeries()
|
||||
})
|
||||
.catch(() => setRenameError('Rename failed'))
|
||||
.finally(() => setRenameSaving(false))
|
||||
}
|
||||
|
||||
const handleDoomScroll = async () => {
|
||||
setDoomScrollLoading(true)
|
||||
try {
|
||||
@@ -457,6 +544,24 @@ export default function TvView({ libraryId }: Props) {
|
||||
>
|
||||
{refreshingMeta ? 'Refreshing…' : 'Refresh metadata'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartEditingMeta}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Edit metadata
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartRename}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
Rename folder
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
@@ -470,19 +575,142 @@ export default function TvView({ libraryId }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
|
||||
{selectedSeries.genres.map((g) => (
|
||||
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span>
|
||||
))}
|
||||
{/* Rename inline input */}
|
||||
{renaming && (
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => setRenaming(false)}
|
||||
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRename}
|
||||
disabled={renameSaving}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{renameSaving ? '…' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{selectedSeries.plot && (
|
||||
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
|
||||
|
||||
{editingMeta ? (
|
||||
<div className="flex flex-col gap-3 mt-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.year}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editForm.plot}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.genres}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setEditingMeta(false)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveSeriesMetadata}
|
||||
disabled={savingMeta}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{savingMeta ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
|
||||
{selectedSeries.genres.map((g) => (
|
||||
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedSeries.plot && (
|
||||
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* NFO refresh warning */}
|
||||
{warnRefresh && (
|
||||
<div
|
||||
className="flex items-center gap-3 mt-3 px-3 py-2.5 rounded-lg"
|
||||
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
|
||||
>
|
||||
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
|
||||
Refreshing from NFO will overwrite your manual edits.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setWarnRefresh(false)}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={doRefreshSeriesMetadata}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
||||
>
|
||||
Overwrite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Confirmation banner */}
|
||||
{confirming && (
|
||||
<div
|
||||
@@ -581,6 +809,29 @@ export default function TvView({ libraryId }: Props) {
|
||||
episode={ep}
|
||||
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
||||
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
|
||||
onDelete={() => {
|
||||
fetch(
|
||||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
|
||||
{ method: 'DELETE' }
|
||||
).then(() => {
|
||||
setEpisodes((prev) => prev.filter((e) => e.id !== ep.id))
|
||||
})
|
||||
}}
|
||||
onRename={async (newName) => {
|
||||
const res = await fetch('/api/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryId, oldPath: ep.videoPath, newName, itemType: 'tv_episode' }),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
// Refetch episodes to get updated data
|
||||
const seasonId = selectedSeason!.id
|
||||
const data = await fetch(
|
||||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&seasonId=${encodeURIComponent(seasonId)}`
|
||||
).then((r) => r.json())
|
||||
setEpisodes(data)
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user