1144 lines
52 KiB
TypeScript
1144 lines
52 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||
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, readOnly }: Props) {
|
||
const [view, setView] = useState<ViewLevel>('series')
|
||
const [series, setSeries] = useState<TvSeries[]>([])
|
||
const [seasons, setSeasons] = useState<TvSeason[]>([])
|
||
const [episodes, setEpisodes] = useState<TvEpisode[]>([])
|
||
const [selectedSeries, setSelectedSeries] = useState<TvSeries | null>(null)
|
||
const [selectedSeason, setSelectedSeason] = useState<TvSeason | null>(null)
|
||
const [playingEpisodeIndex, setPlayingEpisodeIndex] = useState<number | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [search, setSearch] = useState('')
|
||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
|
||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||
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)
|
||
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)
|
||
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) => {
|
||
const next = new Set(prev)
|
||
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
||
return next
|
||
})
|
||
|
||
const fetchSeries = useCallback(() => {
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`)
|
||
.then((r) => r.json())
|
||
.then((data) => { setSeries(data); setLoading(false) })
|
||
.catch(() => { setError('Failed to load TV library'); setLoading(false) })
|
||
}, [libraryId])
|
||
|
||
useEffect(() => { fetchSeries() }, [fetchSeries])
|
||
|
||
const fetchAssignments = useCallback(() => {
|
||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||
.then((r) => r.json())
|
||
.then(setAssignments)
|
||
.catch(() => {})
|
||
}, [libraryId])
|
||
|
||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||
|
||
const fetchSeriesEpisodeTags = useCallback(() => {
|
||
fetch(`/api/tv/series-episode-tags?libraryId=${encodeURIComponent(libraryId)}`)
|
||
.then((r) => r.json())
|
||
.then(setSeriesEpisodeTags)
|
||
.catch(() => {})
|
||
}, [libraryId])
|
||
|
||
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
|
||
|
||
const openSeries = (s: TvSeries) => {
|
||
setSelectedSeriesIndex(filteredSeries.indexOf(s))
|
||
setSelectedSeries(s)
|
||
setView('seasons')
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`)
|
||
.then((r) => r.json())
|
||
.then((data: TvSeason[]) => {
|
||
setSeasons(data)
|
||
setLoading(false)
|
||
})
|
||
.catch(() => { setError('Failed to load seasons'); setLoading(false) })
|
||
}
|
||
|
||
const openSeason = (season: TvSeason, index?: number) => {
|
||
setSelectedSeasonIndex(index ?? seasons.indexOf(season))
|
||
setSelectedSeason(season)
|
||
setView('episodes')
|
||
if (showTagPanel) {
|
||
setTagPanelDisabled(true)
|
||
}
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(
|
||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(season.seriesId)}&seasonId=${encodeURIComponent(season.id)}`
|
||
)
|
||
.then((r) => r.json())
|
||
.then((data) => { setEpisodes(data); setLoading(false) })
|
||
.catch(() => { setError('Failed to load episodes'); setLoading(false) })
|
||
}
|
||
|
||
// Close menu on outside click
|
||
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])
|
||
|
||
const goToSeries = () => {
|
||
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 = () => {
|
||
if (!selectedSeries) return
|
||
setDeleting(true)
|
||
fetch(
|
||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`,
|
||
{ method: 'DELETE' }
|
||
)
|
||
.then(() => {
|
||
setSeries((prev) => prev.filter((s) => s.id !== selectedSeries.id))
|
||
goToSeries()
|
||
setDeleting(false)
|
||
})
|
||
.catch(() => setDeleting(false))
|
||
}
|
||
|
||
const doRefreshSeriesMetadata = () => {
|
||
if (!selectedSeries) return
|
||
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)}&includeEpisodes=true`,
|
||
{ method: 'POST' }
|
||
)
|
||
.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))
|
||
}
|
||
|
||
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 {
|
||
let items: DoomScrollItem[]
|
||
if (filtersActive && filteredSeries.length < series.length) {
|
||
// Fetch episodes only from the filtered series
|
||
const episodeLists = await Promise.all(
|
||
filteredSeries.map(async (s) => {
|
||
const seasons: TvSeason[] = await fetch(
|
||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`
|
||
).then((r) => r.json())
|
||
const seasonEps = await Promise.all(
|
||
seasons.map((season) =>
|
||
fetch(
|
||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}&seasonId=${encodeURIComponent(season.id)}`
|
||
).then((r) => r.json() as Promise<TvEpisode[]>)
|
||
)
|
||
)
|
||
return seasonEps.flat()
|
||
})
|
||
)
|
||
items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
|
||
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
|
||
name: ep.title,
|
||
mediaType: 'video' as const,
|
||
}))
|
||
} else {
|
||
// No filters — fetch all episodes via the TV API hierarchy
|
||
const allSeries: TvSeries[] = await fetch(
|
||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}`
|
||
).then((r) => r.json())
|
||
const episodeLists = await Promise.all(
|
||
allSeries.map(async (s) => {
|
||
const seasons: TvSeason[] = await fetch(
|
||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`
|
||
).then((r) => r.json())
|
||
const seasonEps = await Promise.all(
|
||
seasons.map((season) =>
|
||
fetch(
|
||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}&seasonId=${encodeURIComponent(season.id)}`
|
||
).then((r) => r.json() as Promise<TvEpisode[]>)
|
||
)
|
||
)
|
||
return seasonEps.flat()
|
||
})
|
||
)
|
||
items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
|
||
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
|
||
name: ep.title,
|
||
mediaType: 'video' as const,
|
||
}))
|
||
}
|
||
setDoomScrollItems(items)
|
||
setDoomScrollActive(true)
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setDoomScrollLoading(false)
|
||
}
|
||
}
|
||
|
||
// 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) => {
|
||
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
|
||
if (selectedTagIds.size > 0) {
|
||
const seriesTags = assignments[s.item_key!] ?? []
|
||
const episodeTags = seriesEpisodeTags[s.id] ?? []
|
||
const allTags = seriesTags.length === 0 ? episodeTags
|
||
: episodeTags.length === 0 ? seriesTags
|
||
: [...new Set([...seriesTags, ...episodeTags])]
|
||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
||
}
|
||
return true
|
||
})
|
||
|
||
const filteredEpisodes = episodes.filter((ep) => {
|
||
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
|
||
if (selectedTagIds.size > 0) {
|
||
const epTags = assignments[ep.item_key!] ?? []
|
||
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
|
||
}
|
||
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) {
|
||
const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(playingEpisode.videoPath)}`
|
||
return (
|
||
<VideoPlayerModal
|
||
url={videoUrl}
|
||
name={playingEpisode.title}
|
||
itemKey={playingEpisode.item_key!}
|
||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
|
||
onClose={() => setPlayingEpisodeIndex(null)}
|
||
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}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{doomScrollActive && doomScrollItems.length > 0 && (
|
||
<DoomScrollView
|
||
items={doomScrollItems}
|
||
videoContext="tv"
|
||
onClose={() => setDoomScrollActive(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* Breadcrumb */}
|
||
<div className="flex items-center gap-2 mb-6 text-sm flex-wrap">
|
||
{view !== 'series' ? (
|
||
<button onClick={goToSeries} className="transition-colors" style={{ color: 'var(--accent)' }}>
|
||
All Series
|
||
</button>
|
||
) : (
|
||
<span style={{ color: 'var(--text-secondary)' }}>All Series</span>
|
||
)}
|
||
{selectedSeries && (
|
||
<>
|
||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||
{view === 'episodes' ? (
|
||
<button onClick={goToSeasons} className="transition-colors" style={{ color: 'var(--accent)' }}>
|
||
{selectedSeries.title}
|
||
</button>
|
||
) : (
|
||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||
{selectedSeries.title}
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
{selectedSeason && (
|
||
<>
|
||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||
{selectedSeason.title}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{view === 'series' && (
|
||
<>
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<button
|
||
onClick={() => setShowFilters((v) => !v)}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||
style={{
|
||
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
||
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
||
border: '1px solid var(--border)',
|
||
}}
|
||
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
||
>
|
||
Filters{filtersActive ? ' ●' : ''}
|
||
</button>
|
||
<button
|
||
onClick={handleDoomScroll}
|
||
disabled={doomScrollLoading}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||
style={{
|
||
backgroundColor: 'var(--surface)',
|
||
color: 'var(--text-secondary)',
|
||
border: '1px solid var(--border)',
|
||
}}
|
||
onMouseEnter={(e) => { if (!doomScrollLoading) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)' }}
|
||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)' }}
|
||
>
|
||
{doomScrollLoading ? 'Loading…' : 'Doom Scroll'}
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||
{showFilters && (
|
||
<div className="w-full md:w-52 md:flex-shrink-0">
|
||
<FilterPanel
|
||
libraryId={libraryId}
|
||
assignments={assignments}
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
selectedTagIds={selectedTagIds}
|
||
onTagToggle={toggleTag}
|
||
refreshKey={filterRefreshKey}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
{loading ? (
|
||
<SeriesLoadingGrid />
|
||
) : error ? (
|
||
<ErrorMsg message={error} />
|
||
) : series.length === 0 ? (
|
||
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||
<p className="text-lg mb-1">No TV shows found</p>
|
||
<p className="text-sm">Each series should be a folder containing season subdirectories with video files.</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||
{filteredSeries.map((s) => (
|
||
<div
|
||
key={s.id}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => openSeries(s)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSeries(s) } }}
|
||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2 cursor-pointer"
|
||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||
onMouseEnter={(e) => {
|
||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||
}}
|
||
>
|
||
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||
{s.posterUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={s.posterUrl} alt={s.title} className="absolute inset-0 w-full h-full object-cover" />
|
||
) : (
|
||
<div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div>
|
||
)}
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setTagPanel({ itemKey: s.item_key!, title: s.title }) }}
|
||
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||
aria-label={`Tag ${s.title}`}
|
||
title="Tags"
|
||
>
|
||
🏷
|
||
</button>
|
||
</div>
|
||
<div className="p-2">
|
||
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={s.title}>
|
||
{s.title}
|
||
</p>
|
||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||
{s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</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 */}
|
||
<div className="mb-6 p-4 rounded-xl" style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
|
||
<div className="flex items-start gap-4">
|
||
{selectedSeries.posterUrl && (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={selectedSeries.posterUrl} alt={selectedSeries.title} className="w-16 rounded-lg object-cover flex-shrink-0" style={{ aspectRatio: '2/3' }} />
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-start gap-2">
|
||
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2>
|
||
{/* Kebab menu */}
|
||
<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"
|
||
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
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)' }}
|
||
>
|
||
<button
|
||
onClick={handleRefreshSeriesMetadata}
|
||
disabled={refreshingMeta}
|
||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||
style={{ color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
>
|
||
{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"
|
||
style={{ color: '#fca5a5' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
>
|
||
Delete series
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* 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>
|
||
)}
|
||
|
||
{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>
|
||
)}
|
||
{selectedSeries.item_key && (
|
||
<div className="mt-2">
|
||
<AssignedTagBadges itemKey={selectedSeries.item_key} refreshKey={tagRefreshKey} />
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</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
|
||
className="flex items-center gap-3 mt-3 px-3 py-2.5 rounded-lg"
|
||
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||
>
|
||
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||
Permanently delete this series 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={handleDeleteSeries}
|
||
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>
|
||
)}
|
||
</div>
|
||
|
||
{loading ? (
|
||
<SeasonLoadingGrid />
|
||
) : error ? (
|
||
<ErrorMsg message={error} />
|
||
) : seasons.length === 0 ? (
|
||
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||
No seasons found.
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||
{seasons.map((season) => (
|
||
<button
|
||
key={season.id}
|
||
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) => {
|
||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||
}}
|
||
>
|
||
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||
{season.posterUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={season.posterUrl} alt={season.title} className="absolute inset-0 w-full h-full object-cover" />
|
||
) : (
|
||
<div className="absolute inset-0 flex items-center justify-center text-3xl">📺</div>
|
||
)}
|
||
</div>
|
||
<div className="p-2">
|
||
<p className="text-xs font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||
{season.title}
|
||
</p>
|
||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||
{season.episodeCount} episode{season.episodeCount !== 1 ? 's' : ''}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{view === 'episodes' && selectedSeason && (
|
||
<div className="p-4">
|
||
{loading ? (
|
||
<EpisodeLoadingGrid />
|
||
) : error ? (
|
||
<ErrorMsg message={error} />
|
||
) : episodes.length === 0 ? (
|
||
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||
No episodes found.
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||
{filteredEpisodes.map((ep) => (
|
||
<EpisodeCard
|
||
key={ep.id}
|
||
episode={ep}
|
||
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
||
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!)}`,
|
||
{ 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>
|
||
)}
|
||
</div>
|
||
)}
|
||
</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={() => { 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>
|
||
|
||
{/* 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>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ErrorMsg({ message }: { message: string }) {
|
||
return (
|
||
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||
{message}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SeriesLoadingGrid() {
|
||
return (
|
||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||
{Array.from({ length: 12 }).map((_, i) => (
|
||
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||
<div className="p-2">
|
||
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SeasonLoadingGrid() {
|
||
return (
|
||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||
<div className="p-2">
|
||
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '60%' }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EpisodeLoadingGrid() {
|
||
return (
|
||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||
<div className="aspect-video w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||
<div className="p-2">
|
||
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '80%' }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|