make FilterPanel hideable and responsive across all library views

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>
This commit is contained in:
Garret Patti
2026-04-05 16:29:49 -04:00
parent bc77abbd8b
commit ca4bea084a
5 changed files with 140 additions and 55 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

@@ -49,10 +49,11 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
{mediaKey && ( {mediaKey && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ style={{
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)', backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
color: showTags ? '#fff' : 'var(--text-primary)', color: showTags ? '#fff' : 'var(--text-primary)',
fontSize: '1.5rem',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)' if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
@@ -68,8 +69,8 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
)} )}
<button <button
onClick={onClose} onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }} style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close" aria-label="Close"

View File

@@ -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) => {
@@ -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
@@ -259,8 +279,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</div> </div>
</div> </div>
)} )}
</div>
</div> </div>
</div> </>
) )
} }

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 && (