add library filter, drop folders page
This commit is contained in:
@@ -13,7 +13,6 @@ import HomePage from './pages/HomePage'
|
|||||||
import { useNewReleaseNotifications } from './hooks/use-new-releases'
|
import { useNewReleaseNotifications } from './hooks/use-new-releases'
|
||||||
|
|
||||||
const LibraryPage = lazy(() => import('./pages/LibraryPage'))
|
const LibraryPage = lazy(() => import('./pages/LibraryPage'))
|
||||||
const FoldersPage = lazy(() => import('./pages/FoldersPage'))
|
|
||||||
const DetailPage = lazy(() => import('./pages/DetailPage'))
|
const DetailPage = lazy(() => import('./pages/DetailPage'))
|
||||||
const MusicPage = lazy(() => import('./pages/MusicPage'))
|
const MusicPage = lazy(() => import('./pages/MusicPage'))
|
||||||
const SearchPage = lazy(() => import('./pages/SearchPage'))
|
const SearchPage = lazy(() => import('./pages/SearchPage'))
|
||||||
@@ -234,7 +233,6 @@ export default function App() {
|
|||||||
<Route index element={<PageMotion><LandingRedirect /></PageMotion>} />
|
<Route index element={<PageMotion><LandingRedirect /></PageMotion>} />
|
||||||
<Route path="movies" element={<PageMotion><LibraryPage type="movies" /></PageMotion>} />
|
<Route path="movies" element={<PageMotion><LibraryPage type="movies" /></PageMotion>} />
|
||||||
<Route path="shows" element={<PageMotion><LibraryPage type="shows" /></PageMotion>} />
|
<Route path="shows" element={<PageMotion><LibraryPage type="shows" /></PageMotion>} />
|
||||||
<Route path="folders" element={<PageMotion><FoldersPage /></PageMotion>} />
|
|
||||||
<Route path="music" element={<PageMotion><MusicPage /></PageMotion>} />
|
<Route path="music" element={<PageMotion><MusicPage /></PageMotion>} />
|
||||||
<Route path="playlists" element={<PageMotion><PlaylistsPage /></PageMotion>} />
|
<Route path="playlists" element={<PageMotion><PlaylistsPage /></PageMotion>} />
|
||||||
<Route path="item/:id" element={<PageMotion><DetailPage /></PageMotion>} />
|
<Route path="item/:id" element={<PageMotion><DetailPage /></PageMotion>} />
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const TOP_LEVEL_PATHS = new Set([
|
|||||||
'/',
|
'/',
|
||||||
'/movies',
|
'/movies',
|
||||||
'/shows',
|
'/shows',
|
||||||
'/folders',
|
|
||||||
'/playlists',
|
'/playlists',
|
||||||
'/music',
|
'/music',
|
||||||
'/search',
|
'/search',
|
||||||
@@ -45,7 +44,6 @@ function pageTitleFor(pathname: string): string {
|
|||||||
if (pathname === '/') return 'Home'
|
if (pathname === '/') return 'Home'
|
||||||
if (pathname === '/movies') return 'Movies'
|
if (pathname === '/movies') return 'Movies'
|
||||||
if (pathname === '/shows') return 'TV Shows'
|
if (pathname === '/shows') return 'TV Shows'
|
||||||
if (pathname === '/folders') return 'Folders'
|
|
||||||
if (pathname === '/playlists') return 'Playlists'
|
if (pathname === '/playlists') return 'Playlists'
|
||||||
if (pathname === '/music') return 'Music'
|
if (pathname === '/music') return 'Music'
|
||||||
if (pathname === '/search') return 'Search'
|
if (pathname === '/search') return 'Search'
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Radio,
|
Radio,
|
||||||
Download,
|
Download,
|
||||||
Folders,
|
|
||||||
} from '../../lib/icons'
|
} from '../../lib/icons'
|
||||||
import type { AuthState } from '../../api/types'
|
import type { AuthState } from '../../api/types'
|
||||||
import AppHeader from './AppHeader'
|
import AppHeader from './AppHeader'
|
||||||
@@ -55,7 +54,6 @@ const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: '/', icon: Home, label: 'Home' },
|
{ to: '/', icon: Home, label: 'Home' },
|
||||||
{ to: '/movies', icon: Film, label: 'Movies' },
|
{ to: '/movies', icon: Film, label: 'Movies' },
|
||||||
{ to: '/shows', icon: Tv, label: 'Shows' },
|
{ to: '/shows', icon: Tv, label: 'Shows' },
|
||||||
{ to: '/folders', icon: Folders, label: 'Folders' },
|
|
||||||
{ to: '/playlists', icon: Playlists, label: 'Playlists' },
|
{ to: '/playlists', icon: Playlists, label: 'Playlists' },
|
||||||
{ to: '/live', icon: Radio, label: 'Live TV' },
|
{ to: '/live', icon: Radio, label: 'Live TV' },
|
||||||
{ to: '/requests', icon: Database, label: 'Requests' },
|
{ to: '/requests', icon: Database, label: 'Requests' },
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 { useLibraries, useLibraryItems } from '../hooks/use-jellyfin'
|
||||||
import PosterCard from '../components/ui/PosterCard'
|
import PosterCard from '../components/ui/PosterCard'
|
||||||
import Select, { type SelectOption } from '../components/ui/Select'
|
import Select, { type SelectOption } from '../components/ui/Select'
|
||||||
@@ -59,6 +59,7 @@ export default function LibraryPage({ type }: Props) {
|
|||||||
const [onlyHdr, setOnlyHdr] = useState(false)
|
const [onlyHdr, setOnlyHdr] = useState(false)
|
||||||
const [surpriseOpen, setSurpriseOpen] = useState(false)
|
const [surpriseOpen, setSurpriseOpen] = useState(false)
|
||||||
const [saveOpen, setSaveOpen] = useState(false)
|
const [saveOpen, setSaveOpen] = useState(false)
|
||||||
|
const [libraryFilter, setLibraryFilter] = useState<string>('') // '' = all
|
||||||
const [layoutMode, setLayoutMode] = useState<'grid' | 'map'>(() => {
|
const [layoutMode, setLayoutMode] = useState<'grid' | 'map'>(() => {
|
||||||
if (typeof window === 'undefined') return 'grid'
|
if (typeof window === 'undefined') return 'grid'
|
||||||
return (localStorage.getItem(`lib_layout:${type}`) as 'grid' | 'map') || '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 { data: libraries } = useLibraries()
|
||||||
const collectionType = COLLECTION_TYPE_MAP[type]
|
const collectionType = COLLECTION_TYPE_MAP[type]
|
||||||
const library = libraries?.find(l => l.CollectionType === collectionType)
|
const matchingLibraries = useMemo(
|
||||||
const parentId = library?.Id
|
() => (libraries || []).filter(l => l.CollectionType === collectionType),
|
||||||
|
[libraries, collectionType],
|
||||||
|
)
|
||||||
|
const parentId = libraryFilter || undefined
|
||||||
const includeItemTypes = ITEM_TYPE_MAP[type]
|
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 filtersForApi = useMemo(() => {
|
||||||
const f: string[] = []
|
const f: string[] = []
|
||||||
@@ -233,6 +244,28 @@ export default function LibraryPage({ type }: Props) {
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="mb-6 flex items-center gap-2 flex-wrap">
|
<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} />
|
<WatchedPills value={watchedFilter} onChange={setWatchedFilter} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user