viewer-improvements #6

Merged
gpatti merged 3 commits from viewer-improvements into main 2026-04-05 20:32:18 +00:00
7 changed files with 326 additions and 106 deletions

View File

@@ -21,6 +21,7 @@ export default function GamesView({ libraryId }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -83,20 +84,39 @@ export default function GamesView({ libraryId }: Props) {
return true return true
}) })
const filtersActive = search !== '' || selectedTagIds.size > 0
return ( return (
<div className="flex gap-6 items-start"> <>
<div className="w-52 flex-shrink-0"> <div className="flex items-center gap-2 mb-4">
<FilterPanel <button
libraryId={libraryId} onClick={() => setShowFilters((v) => !v)}
assignments={assignments} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
search={search} style={{
onSearchChange={setSearch} backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
selectedTagIds={selectedTagIds} color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
onTagToggle={toggleTag} border: '1px solid var(--border)',
refreshKey={filterRefreshKey} }}
/> aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
</div> </div>
<div className="flex-1 min-w-0"> <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">
{/* Breadcrumb when inside a series */} {/* Breadcrumb when inside a series */}
{selectedSeries && ( {selectedSeries && (
<div className="flex items-center gap-2 mb-4 text-sm"> <div className="flex items-center gap-2 mb-4 text-sm">
@@ -154,8 +174,9 @@ export default function GamesView({ libraryId }: Props) {
onCoverUploaded={() => fetchGames(true)} onCoverUploaded={() => fetchGames(true)}
/> />
)} )}
</div>
</div> </div>
</div> </>
) )
} }

View File

@@ -1,15 +1,21 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector'
interface Props { interface Props {
url: string url: string
name: string name: string
onClose: () => void onClose: () => void
mediaKey?: string
onTagsChanged?: () => void
} }
export default function ImageLightbox({ url, name, onClose }: Props) { export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChanged }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -30,35 +36,85 @@ export default function ImageLightbox({ url, name, onClose }: Props) {
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 gap-3" className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
style={{ backgroundColor: 'rgba(0,0,0,0.9)' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between w-full max-w-4xl"> <div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name} {name}
</span> </span>
<button <div className="flex items-center gap-2 flex-shrink-0">
onClick={onClose} {mediaKey && (
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors" <button
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }} onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} style={{
aria-label="Close" backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
> color: showTags ? '#fff' : 'var(--text-primary)',
fontSize: '1.5rem',
</button> }}
onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags"
>
🏷
</button>
)}
<button
onClick={onClose}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
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> </div>
{/* Image */} {showTags ? (
{/* eslint-disable-next-line @next/next/no-img-element */} <div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-full">
<img {/* Image */}
src={url} <div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen">
alt={name} {/* eslint-disable-next-line @next/next/no-img-element */}
className="max-w-full max-h-[80vh] object-contain rounded-lg" <img
onClick={(e) => e.stopPropagation()} src={url}
/> alt={name}
className="object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Tag panel */}
<div
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
</div>
</div>
) : (
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="max-w-full max-h-full object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div> </div>
) )
} }

View File

@@ -13,8 +13,8 @@ interface Props {
} }
type ModalState = type ModalState =
| { type: 'video'; url: string; name: string } | { type: 'video'; url: string; name: string; mediaKey: string }
| { type: 'image'; url: string; name: string } | { type: 'image'; url: string; name: string; mediaKey: string }
| null | null
type TagPanelState = { entry: FileEntry; mediaKey: string } | null type TagPanelState = { entry: FileEntry; mediaKey: string } | null
@@ -30,6 +30,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -80,9 +81,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
} }
if (!entry.url) return if (!entry.url) return
if (entry.mediaType === 'video') { if (entry.mediaType === 'video') {
setModal({ type: 'video', url: entry.url, name: entry.name }) setModal({ type: 'video', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
} else if (entry.mediaType === 'image') { } else if (entry.mediaType === 'image') {
setModal({ type: 'image', url: entry.url, name: entry.name }) setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
} else { } else {
// Download other file types // Download other file types
window.open(entry.url, '_blank') window.open(entry.url, '_blank')
@@ -120,20 +121,39 @@ export default function MixedView({ libraryId, initialPath }: Props) {
return true return true
}) })
const filtersActive = search !== '' || selectedTagIds.size > 0
return ( return (
<div className="flex gap-6 items-start"> <>
<div className="w-52 flex-shrink-0"> <div className="flex items-center gap-2 mb-4">
<FilterPanel <button
libraryId={libraryId} onClick={() => setShowFilters((v) => !v)}
assignments={assignments} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
search={search} style={{
onSearchChange={setSearch} backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
selectedTagIds={selectedTagIds} color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
onTagToggle={toggleTag} border: '1px solid var(--border)',
refreshKey={filterRefreshKey} }}
/> aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
</div> </div>
<div className="flex-1 min-w-0"> <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">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm"> <nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
<button <button
@@ -201,10 +221,22 @@ export default function MixedView({ libraryId, initialPath }: Props) {
)} )}
{modal?.type === 'video' && ( {modal?.type === 'video' && (
<VideoPlayerModal url={modal.url} name={modal.name} onClose={() => setModal(null)} /> <VideoPlayerModal
url={modal.url}
name={modal.name}
mediaKey={modal.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
/>
)} )}
{modal?.type === 'image' && ( {modal?.type === 'image' && (
<ImageLightbox url={modal.url} name={modal.name} onClose={() => setModal(null)} /> <ImageLightbox
url={modal.url}
name={modal.name}
mediaKey={modal.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
/>
)} )}
{/* Tag panel */} {/* Tag panel */}
@@ -247,8 +279,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</div> </div>
</div> </div>
)} )}
</div>
</div> </div>
</div> </>
) )
} }

View File

@@ -1,15 +1,21 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector'
interface Props { interface Props {
url: string url: string
name: string name: string
onClose: () => void onClose: () => void
mediaKey?: string
onTagsChanged?: () => void
} }
export default function VideoPlayerModal({ url, name, onClose }: Props) { export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsChanged }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -30,36 +36,90 @@ export default function VideoPlayerModal({ url, name, onClose }: Props) {
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 gap-3" className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
style={{ backgroundColor: 'rgba(0,0,0,0.9)' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between w-full max-w-4xl"> <div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name} {name}
</span> </span>
<button <div className="flex items-center gap-2 flex-shrink-0">
onClick={onClose} {mediaKey && (
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors" <button
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }} onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} style={{
aria-label="Close" backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
> color: showTags ? '#fff' : 'var(--text-primary)',
}}
</button> onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags"
>
🏷
</button>
)}
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
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> </div>
{/* Video */} {showTags ? (
<video <div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
src={url} {/* Video */}
controls <div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full">
autoPlay <video
className="w-full max-w-4xl max-h-[80vh] rounded-lg" src={url}
style={{ backgroundColor: '#000' }} controls
onClick={(e) => e.stopPropagation()} autoPlay
/> muted
loop
className="w-full h-full object-contain rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Tag panel */}
<div
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
</div>
</div>
) : (
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full">
<video
src={url}
controls
autoPlay
muted
loop
className="w-full h-full max-w-4xl object-contain rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div> </div>
) )
} }

View File

@@ -65,7 +65,15 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
} }
if (playing) { if (playing) {
return <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} /> return (
<VideoPlayerModal
url={videoUrl}
name={movie.title}
mediaKey={`${libraryId}:${movie.id}`}
onTagsChanged={onTagsChanged}
onClose={() => setPlaying(false)}
/>
)
} }
const heroUrl = movie.backdropUrl ?? movie.posterUrl const heroUrl = movie.backdropUrl ?? movie.posterUrl

View File

@@ -18,6 +18,7 @@ export default function MoviesView({ libraryId }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -64,20 +65,39 @@ export default function MoviesView({ libraryId }: Props) {
setMovies((prev) => prev.filter((m) => m.id !== movieId)) setMovies((prev) => prev.filter((m) => m.id !== movieId))
} }
const filtersActive = search !== '' || selectedTagIds.size > 0
return ( return (
<div className="flex gap-6 items-start"> <>
<div className="w-52 flex-shrink-0"> <div className="flex items-center gap-2 mb-4">
<FilterPanel <button
libraryId={libraryId} onClick={() => setShowFilters((v) => !v)}
assignments={assignments} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
search={search} style={{
onSearchChange={setSearch} backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
selectedTagIds={selectedTagIds} color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
onTagToggle={toggleTag} border: '1px solid var(--border)',
refreshKey={filterRefreshKey} }}
/> aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
</div> </div>
<div className="flex-1 min-w-0"> <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 ? ( {loading ? (
<LoadingGrid /> <LoadingGrid />
) : error ? ( ) : error ? (
@@ -148,8 +168,9 @@ export default function MoviesView({ libraryId }: Props) {
onDeleted={handleDeleted} onDeleted={handleDeleted}
/> />
)} )}
</div>
</div> </div>
</div> </>
) )
} }

View File

@@ -26,6 +26,7 @@ export default function TvView({ libraryId }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -123,6 +124,8 @@ export default function TvView({ libraryId }: Props) {
.catch(() => setDeleting(false)) .catch(() => setDeleting(false))
} }
const filtersActive = search !== '' || selectedTagIds.size > 0
const filteredSeries = series.filter((s) => { const filteredSeries = series.filter((s) => {
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
@@ -179,19 +182,36 @@ export default function TvView({ libraryId }: Props) {
</div> </div>
{view === 'series' && ( {view === 'series' && (
<div className="flex gap-6 items-start"> <>
<div className="w-52 flex-shrink-0"> <div className="flex items-center gap-2 mb-4">
<FilterPanel <button
libraryId={libraryId} onClick={() => setShowFilters((v) => !v)}
assignments={assignments} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
search={search} style={{
onSearchChange={setSearch} backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
selectedTagIds={selectedTagIds} color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
onTagToggle={toggleTag} border: '1px solid var(--border)',
refreshKey={filterRefreshKey} }}
/> aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
</div> </div>
<div className="flex-1 min-w-0"> <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 ? ( {loading ? (
<SeriesLoadingGrid /> <SeriesLoadingGrid />
) : error ? ( ) : error ? (
@@ -238,8 +258,9 @@ export default function TvView({ libraryId }: Props) {
))} ))}
</div> </div>
)} )}
</div>
</div> </div>
</div> </>
)} )}
{view === 'seasons' && selectedSeries && ( {view === 'seasons' && selectedSeries && (