This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/components/tv/TvView.tsx
Garret Patti 819748d1ff DB-first library reads, mixed library indexing, and manual NFO refresh
- API reads now serve from media_items cache instead of scanning the filesystem
  on every request; scans (manual or scheduled) remain the write path
- NFO metadata is no longer parsed automatically during scans; title falls back
  to folder/filename — metadata can be refreshed per-item via the kabob menu
- Mixed libraries are now indexed in media_items (new mixed_file item type)
  with file_path stored; scanMixed walks recursively and upserts all files
- Added file_path column to media_items and migrated item_type CHECK constraint
  to include mixed_file via safe table-recreation migration
- New POST /api/nfo-refresh endpoint reads the .nfo for a single item and
  patches its DB row (supports movie, tv_series, tv_episode)
- Added "Refresh metadata" button to movie and TV series kabob menus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:20:21 -04:00

667 lines
29 KiB
TypeScript

'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import type { DirectoryListing } from '@/types'
import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import TagSelector from '@/components/tags/TagSelector'
import EpisodeCard from './EpisodeCard'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
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 [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(true)
const [tagPanel, setTagPanel] = useState<{ mediaKey: 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 [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
const [doomScrollLoading, setDoomScrollLoading] = 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 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) => {
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)
// Flat series: a single synthetic season (id='.') means episodes live
// directly in the series folder — skip the seasons screen automatically.
if (data.length === 1 && data[0].id === '.') {
openSeason(data[0])
}
})
.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 handleRefreshSeriesMetadata = () => {
if (!selectedSeries) return
setRefreshingMeta(true)
setMenuOpen(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
{ method: 'POST' }
)
.then(() => fetchSeries())
.finally(() => setRefreshingMeta(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().map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title,
mediaType: 'video' as const,
}))
} else {
// No filters — use full recursive browse
const data: DirectoryListing = await fetch(
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`
).then((r) => r.json())
items = data.entries
.filter((e) => e.type === 'file' && e.mediaType === 'video' && e.url)
.map((e) => ({ url: e.url!, name: e.name, mediaType: 'video' as const }))
}
setDoomScrollItems(items)
setDoomScrollActive(true)
} catch {
// ignore
} finally {
setDoomScrollLoading(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 seriesTags = assignments[`${libraryId}:${s.id}`] ?? []
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[`${libraryId}:${ep.id}`] ?? []
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
}
return true
})
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}
mediaKey={`${libraryId}:${playingEpisode.id}`}
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"
/>
)
}
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({ mediaKey: `${libraryId}:${s.id}`, 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>
</>
)}
{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={() => { 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">
{filteredEpisodes.map((ep) => (
<EpisodeCard
key={ep.id}
episode={ep}
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })}
/>
))}
</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
mediaKey={tagPanel.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
/>
</div>
</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>
)
}