discover components

This commit is contained in:
2026-03-29 05:55:12 +03:00
parent a039249ede
commit 02d65fbeeb
10 changed files with 2161 additions and 0 deletions
+270
View File
@@ -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>
)
}