add movies and tv show library types with Jellyfin NFO support

- Add `movies` type: per-movie folders with video files, poster/backdrop
  images, and optional Jellyfin NFO metadata (title, year, plot, rating,
  genres, runtime). Grid view with 2:3 poster art, detail modal with play
  and two-click delete of the movie folder.
- Add `tv` type: Series -> Season -> Episode hierarchy with lazy loading at
  each level. Reads tvshow.nfo and episodedetails NFO files for metadata.
  Episode grid with video thumbnails, streams via existing video player.
  Delete is limited to the entire series folder to avoid breaking Jellyfin.
- Add fast-xml-parser dependency for Kodi/Jellyfin NFO parsing (lib/nfo.ts)
- Migrate existing DB to expand the libraries CHECK constraint to include
  the two new types; migration is idempotent and preserves existing data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-05 11:36:05 -04:00
parent b3abc7ee4c
commit e8b317f99d
17 changed files with 1589 additions and 4 deletions

View File

@@ -0,0 +1,208 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { Movie } from '@/types'
import TagSelector from '@/components/tags/TagSelector'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
interface Props {
movie: Movie
libraryId: string
onClose: () => void
onTagsChanged?: () => void
onDeleted: (movieId: string) => void
}
export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChanged, onDeleted }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKey)
document.body.style.overflow = ''
}
}, [onClose])
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose()
}
const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}`
const handleDeleteClick = () => {
if (!confirming) {
setConfirming(true)
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
return
}
if (cancelRef.current) clearTimeout(cancelRef.current)
setDeleting(true)
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, {
method: 'DELETE',
})
.then(() => onDeleted(movie.id))
.catch(() => setDeleting(false))
}
const handleCancelDelete = () => {
if (cancelRef.current) clearTimeout(cancelRef.current)
setConfirming(false)
}
if (playing) {
return <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} />
}
const heroUrl = movie.backdropUrl ?? movie.posterUrl
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={handleOverlayClick}
>
<div
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Close"
>
</button>
{/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{heroUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={heroUrl}
alt={movie.title}
className="w-full object-cover max-h-64"
/>
) : (
<div className="h-40 flex items-center justify-center text-5xl">🎬</div>
)}
</div>
{/* Info */}
<div className="p-5">
<div className="flex items-start justify-between gap-3 mb-1">
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
{movie.title}
</h2>
{movie.year && (
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{movie.year}
</span>
)}
</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>
)}
{/* Play button */}
<button
onClick={() => setPlaying(true)}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Play
</button>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} />
</div>
{/* Delete */}
<div className="mt-4 pt-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--border)' }}>
{confirming && (
<p className="text-xs flex-1" style={{ color: 'var(--text-secondary)' }}>
This will permanently delete the folder and all its contents.
</p>
)}
{confirming && (
<button
onClick={handleCancelDelete}
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
)}
<button
onClick={handleDeleteClick}
disabled={deleting}
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0 transition-colors disabled:opacity-50 ml-auto"
style={{
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}
}}
onMouseLeave={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{deleting ? 'Deleting…' : confirming ? 'Confirm delete?' : 'Delete movie'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import type { Movie } from '@/types'
import MovieDetailModal from './MovieDetailModal'
import FilterPanel from '@/components/FilterPanel'
interface Props {
libraryId: string
}
export default function MoviesView({ libraryId }: Props) {
const [movies, setMovies] = useState<Movie[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Movie | 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 toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => {
const next = new Set(prev)
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
return next
})
const fetchMovies = useCallback(() => {
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then((data) => {
setMovies(data)
setLoading(false)
})
.catch(() => {
setError('Failed to load movies')
setLoading(false)
})
}, [libraryId])
useEffect(() => { fetchMovies() }, [fetchMovies])
const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setAssignments)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchAssignments() }, [fetchAssignments])
const filtered = movies.filter((movie) => {
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) {
const movieTags = assignments[`${libraryId}:${movie.id}`] ?? []
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
}
return true
})
const handleDeleted = (movieId: string) => {
setSelected(null)
setMovies((prev) => prev.filter((m) => m.id !== movieId))
}
return (
<div className="flex gap-6 items-start">
<div className="w-52 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 ? (
<LoadingGrid />
) : error ? (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{error}
</div>
) : movies.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 movies found</p>
<p className="text-sm">Each movie should be a folder containing a video file.</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">
{filtered.map((movie) => (
<button
key={movie.id}
onClick={() => setSelected(movie)}
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)' }}>
{movie.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={movie.posterUrl}
alt={movie.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={movie.title}
>
{movie.title}
</p>
{movie.year && (
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{movie.year}
</p>
)}
</div>
</button>
))}
</div>
)}
{selected && (
<MovieDetailModal
movie={selected}
libraryId={libraryId}
onClose={() => setSelected(null)}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onDeleted={handleDeleted}
/>
)}
</div>
</div>
)
}
function LoadingGrid() {
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>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import type { TvEpisode } from '@/types'
interface Props {
episode: TvEpisode
onClick: () => void
}
export default function EpisodeCard({ episode, onClick }: Props) {
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
return (
<button
onClick={onClick}
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-video w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{episode.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={episode.thumbnailUrl}
alt={episode.title}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-3xl"></div>
)}
{/* Play overlay on hover */}
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
>
<span className="text-3xl text-white"></span>
</div>
</div>
<div className="p-2">
{epLabel && (
<p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}>
{epLabel}
</p>
)}
<p
className="text-xs font-medium truncate leading-tight"
style={{ color: 'var(--text-primary)' }}
title={episode.title}
>
{episode.title}
</p>
{episode.aired && (
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{episode.aired}
</p>
)}
</div>
</button>
)
}

View File

@@ -0,0 +1,421 @@
'use client'
import { useEffect, 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 [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
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) })
}
const goToSeries = () => {
setView('series')
setSelectedSeries(null)
setSelectedSeason(null)
setConfirming(false)
}
const goToSeasons = () => {
setView('seasons')
setSelectedSeason(null)
setConfirming(false)
}
const handleDeleteSeries = () => {
if (!selectedSeries) return
if (!confirming) {
setConfirming(true)
setTimeout(() => setConfirming(false), 4000)
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 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 gap-6 items-start">
<div className="w-52 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="flex items-start gap-4 mb-6 p-4 rounded-xl" style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
{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">
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2>
{(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>
{/* Delete series button */}
<div className="flex items-center gap-2 flex-shrink-0">
{confirming && (
<button
onClick={() => setConfirming(false)}
className="text-xs px-2.5 py-1.5 rounded-lg"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
)}
<button
onClick={handleDeleteSeries}
disabled={deleting}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}
}}
onMouseLeave={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
title={confirming ? 'Click again to permanently delete this series and all its files' : 'Delete this series'}
>
{deleting ? 'Deleting…' : confirming ? 'Confirm delete?' : 'Delete series'}
</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>
)
}