library controls and filters
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Bookmark, Trash2 } from '../../lib/icons'
|
||||||
|
import { useSavedSearches, type SavedSearchFilters } from '../../stores/saved-searches-store'
|
||||||
|
|
||||||
|
export function WatchedPills({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: 'any' | 'played' | 'unplayed'
|
||||||
|
onChange: (v: 'any' | 'played' | 'unplayed') => void
|
||||||
|
}) {
|
||||||
|
const opts: Array<{ key: 'any' | 'played' | 'unplayed'; label: string }> = [
|
||||||
|
{ key: 'any', label: 'All' },
|
||||||
|
{ key: 'unplayed', label: 'Unwatched' },
|
||||||
|
{ key: 'played', label: 'Watched' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center bg-elevated/50 border border-border rounded-md p-0.5">
|
||||||
|
{opts.map(o => {
|
||||||
|
const on = value === o.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => onChange(o.key)}
|
||||||
|
className={`h-6 px-2.5 rounded text-[11px] font-medium tracking-tight transition focus-ring ${
|
||||||
|
on ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SavedSearchMenu({
|
||||||
|
scope,
|
||||||
|
onApply,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
scope: 'movies' | 'shows'
|
||||||
|
onApply: (filters: SavedSearchFilters) => void
|
||||||
|
onSave: () => void
|
||||||
|
}) {
|
||||||
|
// Select the whole array; filter in render. Returning a fresh array from
|
||||||
|
// the selector breaks Zustand's snapshot identity check and sends
|
||||||
|
// useSyncExternalStore into an update loop.
|
||||||
|
const allSearches = useSavedSearches(s => s.searches)
|
||||||
|
const searches = useMemo(
|
||||||
|
() => allSearches.filter(x => x.scope === scope),
|
||||||
|
[allSearches, scope],
|
||||||
|
)
|
||||||
|
const remove = useSavedSearches(s => s.remove)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md text-[11.5px] font-medium tracking-tight bg-elevated/50 text-text-2 ring-1 ring-border hover:text-text-1 transition focus-ring"
|
||||||
|
>
|
||||||
|
<Bookmark size={11} stroke={2} />
|
||||||
|
Saved
|
||||||
|
{searches.length > 0 && (
|
||||||
|
<span className="text-text-4 tabular-nums">{searches.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute left-0 top-full mt-1.5 z-40 min-w-[220px] rounded-xl bg-surface ring-1 ring-border-strong shadow-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onSave()
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 text-[12px] hover:bg-elevated/80 text-accent font-medium transition border-b border-border"
|
||||||
|
>
|
||||||
|
Save current filters as...
|
||||||
|
</button>
|
||||||
|
{searches.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-[11.5px] text-text-4 text-center">
|
||||||
|
No saved searches yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
searches.map(s => (
|
||||||
|
<div key={s.id} className="flex items-center group">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onApply(s.filters)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex-1 text-left px-3 py-2 text-[12px] hover:bg-elevated/80 text-text-2 transition truncate"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(s.id)}
|
||||||
|
aria-label="Delete saved search"
|
||||||
|
className="px-2.5 py-2 text-text-4 hover:text-red-300 transition opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Shuffle } from '../../lib/icons'
|
||||||
|
import { useSavedSearches, type SavedSearchFilters } from '../../stores/saved-searches-store'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
|
||||||
|
export function SaveSearchModal({
|
||||||
|
open,
|
||||||
|
scope,
|
||||||
|
filters,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
scope: 'movies' | 'shows'
|
||||||
|
filters: SavedSearchFilters
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const add = useSavedSearches(s => s.add)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
function save() {
|
||||||
|
if (!name.trim()) return
|
||||||
|
add(scope, name, filters)
|
||||||
|
setName('')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-[80] grid place-items-center bg-black/65 backdrop-blur-sm p-6"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 20, scale: 0.96 }}
|
||||||
|
animate={{ y: 0, scale: 1 }}
|
||||||
|
exit={{ y: 20, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="relative w-full max-w-sm rounded-2xl bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] p-5"
|
||||||
|
>
|
||||||
|
<h2 className="text-[15px] font-semibold tracking-tight text-text-1 mb-1">
|
||||||
|
Save this search
|
||||||
|
</h2>
|
||||||
|
<p className="text-[12px] text-text-3 mb-4">
|
||||||
|
Capture the current filters under a name you can recall later.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') save() }}
|
||||||
|
placeholder='e.g. "Unwatched 4K HDR"'
|
||||||
|
autoFocus
|
||||||
|
className="w-full h-10 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[13px] tracking-tight"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-9 px-4 rounded-full text-[12px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="h-9 px-5 rounded-full bg-accent text-void text-[12px] font-semibold tracking-tight transition disabled:opacity-40 hover:bg-accent-hover focus-ring"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SurpriseMeModal({
|
||||||
|
open,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
items: BaseItemDto[]
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [pick, setPick] = useState<BaseItemDto | null>(null)
|
||||||
|
|
||||||
|
function roll() {
|
||||||
|
if (items.length === 0) {
|
||||||
|
setPick(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idx = Math.floor(Math.random() * items.length)
|
||||||
|
setPick(items[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll a fresh pick when the modal opens (or items change while open),
|
||||||
|
// and clear when it closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && items.length > 0) {
|
||||||
|
const idx = Math.floor(Math.random() * items.length)
|
||||||
|
setPick(items[idx])
|
||||||
|
} else if (!open) {
|
||||||
|
setPick(null)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, items.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && pick && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-[80] grid place-items-center bg-black/65 backdrop-blur-sm p-6"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 20, scale: 0.96 }}
|
||||||
|
animate={{ y: 0, scale: 1 }}
|
||||||
|
exit={{ y: 20, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="relative w-full max-w-md rounded-2xl bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] p-5"
|
||||||
|
>
|
||||||
|
<p className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
|
||||||
|
Surprise me
|
||||||
|
</p>
|
||||||
|
<h2 className="text-[18px] font-semibold tracking-tight text-text-1 mb-3 leading-tight">
|
||||||
|
{pick.Name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[12.5px] text-text-3 mb-4 line-clamp-4 leading-relaxed">
|
||||||
|
{pick.Overview || 'No description available.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={roll}
|
||||||
|
className="h-9 px-4 rounded-full text-[12px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Shuffle size={12} stroke={2} />
|
||||||
|
Roll again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (pick.Id) navigate(`/item/${pick.Id}`)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="h-9 px-5 rounded-full bg-accent text-void text-[12px] font-semibold tracking-tight transition hover:bg-accent-hover focus-ring"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Film } from '../../lib/icons'
|
||||||
|
import { usePosterGridClasses } from '../../lib/density'
|
||||||
|
|
||||||
|
export function EmptyLibrary({ type, Icon }: { type: string; Icon: typeof Film }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
|
||||||
|
<div className="relative w-16 h-16 mb-4">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
||||||
|
<Icon size={22} className="text-text-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium text-text-1 mb-1.5">No {type} found</p>
|
||||||
|
<p className="text-[12px] text-text-4 max-w-sm">
|
||||||
|
Add media to your Jellyfin library to see it here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PosterGridSkeleton() {
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
return (
|
||||||
|
<div className={gridCls}>
|
||||||
|
{Array.from({ length: 24 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="skeleton aspect-[2/3] rounded-lg" />
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="skeleton h-3 w-3/4 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user