Adds a toggle button to show/hide the filter panel in Movies, Games, Mixed, and TV views. On mobile the layout stacks vertically (filter above content); on md+ it returns to the side-by-side layout. The toggle button highlights when filters are active so hidden filters remain discoverable. Also fixes a layout bug where items-start on the flex-col container caused MixedView thumbnails to collapse on narrow screens. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
480 lines
20 KiB
TypeScript
480 lines
20 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 EpisodeCard from './EpisodeCard'
|
|
|
|
interface Props {
|
|
libraryId: string
|
|
}
|
|
|
|
type ViewLevel = 'series' | 'seasons' | 'episodes'
|
|
|
|
export default function TvView({ libraryId }: 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 [playingEpisode, setPlayingEpisode] = useState<TvEpisode | 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 [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
|
const [showFilters, setShowFilters] = useState(true)
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const [confirming, setConfirming] = useState(false)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
|
|
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 openSeries = (s: TvSeries) => {
|
|
setSelectedSeries(s)
|
|
setView('seasons')
|
|
setLoading(true)
|
|
setError(null)
|
|
fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`)
|
|
.then((r) => r.json())
|
|
.then((data) => { setSeasons(data); setLoading(false) })
|
|
.catch(() => { setError('Failed to load seasons'); setLoading(false) })
|
|
}
|
|
|
|
const openSeason = (season: TvSeason) => {
|
|
setSelectedSeason(season)
|
|
setView('episodes')
|
|
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)
|
|
setMenuOpen(false)
|
|
setConfirming(false)
|
|
}
|
|
|
|
const goToSeasons = () => {
|
|
setView('seasons')
|
|
setSelectedSeason(null)
|
|
setConfirming(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 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 tags = assignments[`${libraryId}:${s.id}`] ?? []
|
|
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
if (playingEpisode) {
|
|
const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(playingEpisode.videoPath)}`
|
|
return (
|
|
<VideoPlayerModal
|
|
url={videoUrl}
|
|
name={playingEpisode.title}
|
|
onClose={() => setPlayingEpisode(null)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* 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>
|
|
</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) => (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => openSeries(s)}
|
|
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)' }}>
|
|
{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>
|
|
)}
|
|
</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>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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={() => { 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>
|
|
{(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>
|
|
{/* 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)}
|
|
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>
|
|
{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">
|
|
{episodes.map((ep) => (
|
|
<EpisodeCard
|
|
key={ep.id}
|
|
episode={ep}
|
|
onClick={() => setPlayingEpisode(ep)}
|
|
/>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|