From 1078149613f895fdb53bc13cc5a3b2ba09d1c5b3 Mon Sep 17 00:00:00 2001 From: lashman Date: Wed, 1 Apr 2026 08:12:28 +0300 Subject: [PATCH] library controls and filters --- src/pages/library/controls.tsx | 112 ++++++++++++++++++++++ src/pages/library/modals.tsx | 167 +++++++++++++++++++++++++++++++++ src/pages/library/states.tsx | 36 +++++++ 3 files changed, 315 insertions(+) create mode 100644 src/pages/library/controls.tsx create mode 100644 src/pages/library/modals.tsx create mode 100644 src/pages/library/states.tsx diff --git a/src/pages/library/controls.tsx b/src/pages/library/controls.tsx new file mode 100644 index 0000000..86f1dec --- /dev/null +++ b/src/pages/library/controls.tsx @@ -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 ( +
+ {opts.map(o => { + const on = value === o.key + return ( + + ) + })} +
+ ) +} + +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 ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ + {searches.length === 0 ? ( +

+ No saved searches yet. +

+ ) : ( + searches.map(s => ( +
+ + +
+ )) + )} +
+ + )} +
+ ) +} diff --git a/src/pages/library/modals.tsx b/src/pages/library/modals.tsx new file mode 100644 index 0000000..df4e992 --- /dev/null +++ b/src/pages/library/modals.tsx @@ -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 ( + + {open && ( + + 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" + > +

+ Save this search +

+

+ Capture the current filters under a name you can recall later. +

+ 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" + /> +
+ + +
+
+
+ )} +
+ ) +} + +export function SurpriseMeModal({ + open, + items, + onClose, +}: { + open: boolean + items: BaseItemDto[] + onClose: () => void +}) { + const navigate = useNavigate() + const [pick, setPick] = useState(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 ( + + {open && pick && ( + + 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" + > +

+ Surprise me +

+

+ {pick.Name} +

+

+ {pick.Overview || 'No description available.'} +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/library/states.tsx b/src/pages/library/states.tsx new file mode 100644 index 0000000..c4216a9 --- /dev/null +++ b/src/pages/library/states.tsx @@ -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 ( +
+
+
+
+ +
+
+

No {type} found

+

+ Add media to your Jellyfin library to see it here. +

+
+ ) +} + +export function PosterGridSkeleton() { + const gridCls = usePosterGridClasses() + return ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) +}