library controls and filters
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user