discover components
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Shuffle, RotateCw, ArrowRight, Star, X } from '../../lib/icons'
|
||||
import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb'
|
||||
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { DISCOVER_MOODS } from '../../lib/discover-moods'
|
||||
import { filterToMissing } from '../../pages/discover/helpers'
|
||||
|
||||
const TMDB_IMG = 'https://image.tmdb.org/t/p'
|
||||
|
||||
interface Props {
|
||||
kind: 'movie' | 'tv'
|
||||
/** Optional mood id - when set, the roulette picks from that mood's pool. */
|
||||
moodId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* "Pick for me" roulette. Pulls a pool of titles matching the current
|
||||
* Discover context (mood when active, otherwise top-rated by popularity),
|
||||
* filters out items the user already owns, and surfaces a single random
|
||||
* pick in a centered modal. Cheap dopamine hit + cure for choice paralysis.
|
||||
*/
|
||||
export default function Roulette({ kind, moodId }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
title="Pick something for me"
|
||||
aria-label="Pick something for me"
|
||||
className="inline-flex items-center gap-2 h-10 pl-3.5 pr-4 rounded-full bg-cool/15 hover:bg-cool/22 text-cool border border-cool/30 hover:border-cool/45 text-[12.5px] font-semibold tracking-tight transition-all duration-200 hover:scale-[1.03] active:scale-[0.97] focus-ring"
|
||||
>
|
||||
<Shuffle size={14} stroke={2.25} />
|
||||
Pick for me
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<RouletteModal kind={kind} moodId={moodId} onClose={() => setOpen(false)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RouletteModal({
|
||||
kind,
|
||||
moodId,
|
||||
onClose,
|
||||
}: {
|
||||
kind: 'movie' | 'tv'
|
||||
moodId?: string | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const lib = useLibraryByTmdbId()
|
||||
|
||||
// Resolve the source query based on context. Mood wins when present;
|
||||
// otherwise fall back to popularity-sorted, well-voted picks.
|
||||
const mood = moodId ? DISCOVER_MOODS.find(m => m.id === moodId) : null
|
||||
const params =
|
||||
mood && (kind === 'movie' ? mood.movieParams : mood.tvParams)
|
||||
|| {
|
||||
sort_by: 'popularity.desc',
|
||||
'vote_count.gte': '1000',
|
||||
'vote_average.gte': '6.5',
|
||||
}
|
||||
|
||||
const movies = useTmdbDiscoverMovies(kind === 'movie' ? params : {})
|
||||
const tv = useTmdbDiscoverTv(kind === 'tv' ? params : {})
|
||||
const data = kind === 'movie' ? movies.data : tv.data
|
||||
const isLoading = kind === 'movie' ? movies.isLoading : tv.isLoading
|
||||
|
||||
const pool = useMemo(() => {
|
||||
const raw = (data?.results || []).map(m => ({ ...m, media_type: kind }))
|
||||
let filtered = filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult)
|
||||
if (mood?.extra) filtered = filtered.filter(mood.extra as any)
|
||||
return filtered.filter((m: any) => m.poster_path && m.overview)
|
||||
}, [data, lib.data, hideAdult, kind, mood])
|
||||
|
||||
const [pickIndex, setPickIndex] = useState(() => Math.floor(Math.random() * 20))
|
||||
const [spinNonce, setSpinNonce] = useState(0)
|
||||
|
||||
// Whenever the pool changes (mood swap / data loads), reseed the pick.
|
||||
useEffect(() => {
|
||||
if (pool.length > 0) {
|
||||
setPickIndex(Math.floor(Math.random() * pool.length))
|
||||
}
|
||||
}, [pool.length, moodId, kind])
|
||||
|
||||
const pick = pool[pickIndex % Math.max(1, pool.length)] || null
|
||||
|
||||
function spin() {
|
||||
if (pool.length < 2) return
|
||||
let next = pickIndex
|
||||
// Avoid landing on the same pick consecutively.
|
||||
while (next === pickIndex) next = Math.floor(Math.random() * pool.length)
|
||||
setPickIndex(next)
|
||||
setSpinNonce(n => n + 1)
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!pick) return
|
||||
const mediaType = (pick as any).media_type === 'tv' || (pick as any).first_air_date ? 'tv' : 'movie'
|
||||
navigate(`/item/tmdb-${mediaType}-${(pick as any).id}`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
spin()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pickIndex, pool.length])
|
||||
|
||||
const p = pick as any
|
||||
const title = p?.title || p?.name || ''
|
||||
const year = (p?.release_date || p?.first_air_date || '').slice(0, 4)
|
||||
const rating = typeof p?.vote_average === 'number' ? p.vote_average.toFixed(1) : null
|
||||
const overview: string = p?.overview || ''
|
||||
const backdrop = p?.backdrop_path ? `${TMDB_IMG}/w1280${p.backdrop_path}` : null
|
||||
const poster = p?.poster_path ? `${TMDB_IMG}/w500${p.poster_path}` : null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 z-toast grid place-items-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.92, y: 16 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 8 }}
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="relative w-[760px] max-w-[92vw] rounded-2xl overflow-hidden bg-[#0c0a08]/97 ring-1 ring-white/14 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.85)]"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="absolute top-3 right-3 z-20 w-9 h-9 grid place-items-center rounded-full text-white/75 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
{/* Backdrop band */}
|
||||
<div className="relative h-[260px] overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`bg-${spinNonce}`}
|
||||
initial={{ opacity: 0, scale: 1.08 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{backdrop && (
|
||||
<img src={backdrop} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0c0a08] via-[#0c0a08]/55 to-[#0c0a08]/20" />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div className="absolute top-5 left-5 flex items-center gap-2 z-10">
|
||||
<span className="w-1 h-3 rounded-full bg-cool" />
|
||||
<span className="text-[10.5px] uppercase tracking-[0.2em] font-semibold text-cool">
|
||||
Pick for you
|
||||
</span>
|
||||
{mood && (
|
||||
<span className="text-[10.5px] text-white/55 tracking-tight">
|
||||
- {mood.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="-mt-24 relative px-7 pb-7 flex gap-5">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`poster-${spinNonce}`}
|
||||
initial={{ opacity: 0, y: 16, rotateY: 28 }}
|
||||
animate={{ opacity: 1, y: 0, rotateY: 0 }}
|
||||
exit={{ opacity: 0, y: -8, rotateY: -16 }}
|
||||
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="shrink-0 w-[140px] aspect-[2/3] rounded-xl overflow-hidden ring-1 ring-white/12 shadow-[0_18px_36px_-12px_rgba(0,0,0,0.8)] bg-elevated"
|
||||
>
|
||||
{poster ? (
|
||||
<img src={poster} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center text-white/40 text-3xl font-display">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex-1 min-w-0 pt-24">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`info-${spinNonce}`}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<h2 className="font-display text-[22px] md:text-[26px] font-bold text-white tracking-tight leading-[1.1] mb-1.5">
|
||||
{title || (isLoading ? 'Spinning...' : 'No matches')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-[11.5px] text-white/65 mb-3 tabular-nums">
|
||||
{year && <span>{year}</span>}
|
||||
{year && rating && <span className="text-white/30">·</span>}
|
||||
{rating && (
|
||||
<span className="inline-flex items-center gap-1 text-accent">
|
||||
<Star size={11} fill="currentColor" stroke={0} />
|
||||
{rating}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/30">·</span>
|
||||
<span className="uppercase tracking-[0.14em] text-[10px] font-semibold text-white/55">
|
||||
{kind === 'tv' ? 'Series' : 'Movie'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-white/80 leading-relaxed line-clamp-4">
|
||||
{overview || (isLoading ? 'Pulling a pick...' : 'Try a different mood - this pool came back empty.')}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-7 pb-6 flex items-center gap-2.5">
|
||||
<button
|
||||
onClick={open}
|
||||
disabled={!pick}
|
||||
className="inline-flex items-center gap-2 h-10 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight disabled:opacity-40 disabled:cursor-not-allowed transition-transform duration-200 hover:scale-[1.03] active:scale-[0.97] focus-ring shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]"
|
||||
>
|
||||
View details
|
||||
<ArrowRight size={13} stroke={2.25} />
|
||||
</button>
|
||||
<button
|
||||
onClick={spin}
|
||||
disabled={pool.length < 2}
|
||||
className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-white/8 hover:bg-white/14 text-white/90 border border-white/15 hover:border-white/25 text-[12.5px] font-medium tracking-tight disabled:opacity-40 disabled:cursor-not-allowed transition-colors focus-ring"
|
||||
title="Spin again (Space)"
|
||||
>
|
||||
<RotateCw size={13} stroke={2.25} />
|
||||
Spin again
|
||||
</button>
|
||||
<span className="ml-auto text-[10.5px] text-white/40 tabular-nums">
|
||||
{pool.length > 0 ? `${pool.length} in pool` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user