fix tv view
This commit is contained in:
@@ -6,6 +6,7 @@ import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||
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'
|
||||
@@ -48,7 +49,12 @@ export default function TvView({ libraryId }: Props) {
|
||||
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) => {
|
||||
@@ -108,6 +114,9 @@ export default function TvView({ libraryId }: Props) {
|
||||
const openSeason = (season: TvSeason) => {
|
||||
setSelectedSeason(season)
|
||||
setView('episodes')
|
||||
if (showTagPanel) {
|
||||
setTagPanelDisabled(true)
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetch(
|
||||
@@ -136,12 +145,19 @@ export default function TvView({ libraryId }: Props) {
|
||||
setSelectedSeason(null)
|
||||
setMenuOpen(false)
|
||||
setConfirming(false)
|
||||
setShowTagPanel(false)
|
||||
setTagPanelItemKey(null)
|
||||
setTagPanelDisabled(false)
|
||||
}
|
||||
|
||||
const goToSeasons = () => {
|
||||
setView('seasons')
|
||||
setSelectedSeason(null)
|
||||
setConfirming(false)
|
||||
if (showTagPanel && selectedSeries?.item_key) {
|
||||
setTagPanelItemKey(selectedSeries.item_key)
|
||||
setTagPanelDisabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSeries = () => {
|
||||
@@ -312,6 +328,40 @@ export default function TvView({ libraryId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
@@ -502,9 +552,76 @@ export default function TvView({ libraryId }: Props) {
|
||||
)}
|
||||
</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-start 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 */}
|
||||
@@ -682,6 +799,11 @@ export default function TvView({ libraryId }: Props) {
|
||||
{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>
|
||||
@@ -792,7 +914,7 @@ export default function TvView({ libraryId }: Props) {
|
||||
)}
|
||||
|
||||
{view === 'episodes' && selectedSeason && (
|
||||
<div>
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<EpisodeLoadingGrid />
|
||||
) : error ? (
|
||||
@@ -808,7 +930,7 @@ export default function TvView({ libraryId }: Props) {
|
||||
key={ep.id}
|
||||
episode={ep}
|
||||
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
||||
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
|
||||
onTag={() => { setTagPanelItemKey(ep.item_key!); setTagPanelDisabled(false); setShowTagPanel(true) }}
|
||||
onDelete={() => {
|
||||
fetch(
|
||||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
|
||||
@@ -838,42 +960,93 @@ export default function TvView({ libraryId }: Props) {
|
||||
)}
|
||||
</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>
|
||||
</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"
|
||||
|
||||
{/* 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 && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Right tag panel */}
|
||||
{showTagPanel && (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<TagSelector
|
||||
itemKey={tagPanel.itemKey}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowTagPanel(false)}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Hide panel"
|
||||
title="Hide panel"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={goToSeries}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
{tagPanelDisabled ? (
|
||||
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
|
||||
Seasons cannot be tagged. Select an episode to tag it.
|
||||
</p>
|
||||
) : tagPanelItemKey ? (
|
||||
<>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<TagSelector
|
||||
itemKey={tagPanelItemKey}
|
||||
onTagsChanged={() => {
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
setFilterRefreshKey((k) => k + 1)
|
||||
fetchAssignments()
|
||||
fetchSeriesEpisodeTags()
|
||||
}}
|
||||
refreshKey={tagRefreshKey}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user