add library filter, drop folders page

This commit is contained in:
2026-06-05 13:27:01 +03:00
parent cc01004b49
commit 0700226477
5 changed files with 36 additions and 144 deletions
-135
View File
@@ -1,135 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { useLibraries, useLibraryItems } from '../hooks/use-jellyfin'
import { Folder } from '../lib/icons'
import type { BaseItemDto } from '../api/types'
export default function FoldersPage() {
const { data: libraries, isLoading } = useLibraries()
const navigate = useNavigate()
const userLibraries = (libraries || []).filter(l => l.CollectionType)
return (
<div className="px-7 pt-4 pb-12">
<header className="mb-7">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Folders
</h1>
<p className="text-text-3 text-[13px] mt-1.5 max-w-prose">
Browse libraries by folder. Each library section below shows the top-level folders Jellyfin scanned; click one to see only its items.
</p>
</header>
{isLoading ? (
<FoldersSkeleton />
) : userLibraries.length === 0 ? (
<p className="text-text-3 text-[13px]">No libraries found.</p>
) : (
<div className="space-y-10">
{userLibraries.map(library => (
<LibrarySection
key={library.Id}
library={library}
onOpen={id => navigate(`/item/${id}`)}
/>
))}
</div>
)}
</div>
)
}
function LibrarySection({
library,
onOpen,
}: {
library: BaseItemDto
onOpen: (id: string) => void
}) {
const { data, isLoading } = useLibraryItems(library.Id, {
recursive: false,
sortBy: ['SortName'],
limit: 50,
})
const subfolders = (data?.Items || []).filter(
(it: BaseItemDto) => it.Id && (it.Type === 'CollectionFolder' || it.IsFolder),
)
return (
<section>
<div className="flex items-baseline gap-3 mb-3">
<h2 className="text-xl font-semibold text-text-1 font-display tracking-tight">
{library.Name}
</h2>
<span className="text-[11.5px] text-text-4 tabular-nums">
{subfolders.length} {subfolders.length === 1 ? 'folder' : 'folders'}
</span>
</div>
{isLoading ? (
<FoldersSkeleton compact />
) : subfolders.length === 0 ? (
<p className="text-text-3 text-[12.5px]">
No subfolders in this library. Use Movies / TV Shows in the sidebar to browse everything.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{subfolders.map(folder => (
<FolderCard
key={folder.Id}
name={folder.Name || 'Folder'}
count={folder.RecursiveItemCount ?? folder.ChildCount ?? 0}
onClick={() => onOpen(folder.Id!)}
/>
))}
</div>
)}
</section>
)
}
function FolderCard({
name,
count,
onClick,
}: {
name: string
count: number
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="text-left rounded-xl border border-border bg-elevated/40 hover:border-accent hover:bg-elevated transition p-4 group focus-ring"
>
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-accent/15 text-accent mb-3 group-hover:bg-accent/25">
<Folder size={20} stroke={1.8} />
</div>
<h3 className="font-semibold text-text-1 truncate" title={name}>
{name}
</h3>
<p className="text-[11.5px] text-text-3 mt-1 tabular-nums">
{count.toLocaleString()} {count === 1 ? 'item' : 'items'}
</p>
</button>
)
}
function FoldersSkeleton({ compact = false }: { compact?: boolean }) {
const count = compact ? 4 : 8
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-elevated/40 p-4">
<div className="skeleton w-10 h-10 rounded-lg mb-3" />
<div className="skeleton h-3.5 w-3/4 rounded" />
<div className="skeleton h-2.5 w-1/2 rounded mt-2" />
</div>
))}
</div>
)
}
+36 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { ArrowDownAZ, Calendar, Shuffle, Film, Tv, Filter, X, Star } from '../lib/icons'
import { ArrowDownAZ, Calendar, Shuffle, Film, Tv, Filter, X, Star, Library } from '../lib/icons'
import { useLibraries, useLibraryItems } from '../hooks/use-jellyfin'
import PosterCard from '../components/ui/PosterCard'
import Select, { type SelectOption } from '../components/ui/Select'
@@ -59,6 +59,7 @@ export default function LibraryPage({ type }: Props) {
const [onlyHdr, setOnlyHdr] = useState(false)
const [surpriseOpen, setSurpriseOpen] = useState(false)
const [saveOpen, setSaveOpen] = useState(false)
const [libraryFilter, setLibraryFilter] = useState<string>('') // '' = all
const [layoutMode, setLayoutMode] = useState<'grid' | 'map'>(() => {
if (typeof window === 'undefined') return 'grid'
return (localStorage.getItem(`lib_layout:${type}`) as 'grid' | 'map') || 'grid'
@@ -96,9 +97,19 @@ export default function LibraryPage({ type }: Props) {
const { data: libraries } = useLibraries()
const collectionType = COLLECTION_TYPE_MAP[type]
const library = libraries?.find(l => l.CollectionType === collectionType)
const parentId = library?.Id
const matchingLibraries = useMemo(
() => (libraries || []).filter(l => l.CollectionType === collectionType),
[libraries, collectionType],
)
const parentId = libraryFilter || undefined
const includeItemTypes = ITEM_TYPE_MAP[type]
// If the previously selected library disappeared (e.g., the user
// removed it on the server), fall back to "all libraries".
useEffect(() => {
if (libraryFilter && !matchingLibraries.some(l => l.Id === libraryFilter)) {
setLibraryFilter('')
}
}, [libraryFilter, matchingLibraries])
const filtersForApi = useMemo(() => {
const f: string[] = []
@@ -233,6 +244,28 @@ export default function LibraryPage({ type }: Props) {
{/* Filters */}
<div className="mb-6 flex items-center gap-2 flex-wrap">
{matchingLibraries.length > 1 && (
<Select
size="sm"
ariaLabel="Filter by library"
triggerIcon={<Library size={11} stroke={2} />}
value={libraryFilter || '__all__'}
onChange={v => setLibraryFilter(v === '__all__' ? '' : v)}
width="min-w-[160px]"
options={[
{
value: '__all__',
label: `All libraries (${matchingLibraries.length})`,
muted: true,
} as SelectOption<string>,
...matchingLibraries.map<SelectOption<string>>(l => ({
value: l.Id!,
label: l.Name || 'Library',
})),
]}
/>
)}
<WatchedPills value={watchedFilter} onChange={setWatchedFilter} />
<button