discover components
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Star, MoonStars } from '../../lib/icons'
|
||||
import { useTmdbDiscoverMovies, useTmdbDiscoverTv, useTmdbTrending } from '../../hooks/use-tmdb'
|
||||
import { useLibraryByTmdbId, useLibraryGenreDistribution } from '../../hooks/use-jellyfin'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { filterToMissing } from '../../pages/discover/helpers'
|
||||
import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres'
|
||||
|
||||
const TMDB_IMG = 'https://image.tmdb.org/t/p'
|
||||
|
||||
interface Props {
|
||||
kind: 'movie' | 'tv'
|
||||
}
|
||||
|
||||
/**
|
||||
* Personalized "tonight" pick. Reads the user's library genre
|
||||
* distribution, finds their top genre, and picks one highly-rated
|
||||
* unowned title from that genre as a featured spotlight.
|
||||
*
|
||||
* Falls back to TMDB trending-day for cold-start users (small or no
|
||||
* library), so the component always renders something useful.
|
||||
*
|
||||
* The card mirrors the older SpotlightHero visually but reframes the
|
||||
* copy: this isn't "what's hot on TMDB", it's "we read what you
|
||||
* actually watch".
|
||||
*/
|
||||
export default function TonightHero({ kind }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const lib = useLibraryByTmdbId()
|
||||
const dist = useLibraryGenreDistribution()
|
||||
|
||||
// Top genre across the user's library, if we have enough material to
|
||||
// call it a real signal. Below 30 items we treat the user as
|
||||
// cold-start and fall back to a non-personalized pick.
|
||||
const topGenre = useMemo(() => {
|
||||
const data = dist.data
|
||||
if (!data || data.total < 30) return null
|
||||
const sorted = [...data.counts.entries()].sort((a, b) => b[1] - a[1])
|
||||
return sorted[0]?.[0] || null
|
||||
}, [dist.data])
|
||||
|
||||
const genreId = topGenre
|
||||
? kind === 'movie'
|
||||
? tmdbMovieGenreId(topGenre)
|
||||
: tmdbTvGenreId(topGenre)
|
||||
: null
|
||||
|
||||
const personalizedParams = genreId
|
||||
? {
|
||||
with_genres: String(genreId),
|
||||
'vote_count.gte': '2000',
|
||||
'vote_average.gte': '7.5',
|
||||
sort_by: 'popularity.desc',
|
||||
}
|
||||
: ({} as Record<string, string>)
|
||||
|
||||
const personalMovies = useTmdbDiscoverMovies(kind === 'movie' && genreId ? personalizedParams : {})
|
||||
const personalTv = useTmdbDiscoverTv(kind === 'tv' && genreId ? personalizedParams : {})
|
||||
const personalData = kind === 'movie' ? personalMovies.data : personalTv.data
|
||||
|
||||
// Cold-start fallback: trending-day, same as the old Spotlight.
|
||||
const trending = useTmdbTrending(kind, 'day')
|
||||
|
||||
const pick = useMemo(() => {
|
||||
// Personalized pool wins when present + non-empty.
|
||||
const personalRaw = (personalData?.results || []).map(m => ({ ...m, media_type: kind }))
|
||||
const personalPool = filterToMissing(personalRaw, lib.data, hideAdult, m => !!(m as any).adult)
|
||||
.filter((m: any) => m.backdrop_path && m.overview)
|
||||
if (personalPool.length > 0) {
|
||||
return { item: personalPool[0], reason: 'personalized' as const, genre: topGenre }
|
||||
}
|
||||
// Fallback: trending-day filtered to unowned.
|
||||
const trendRaw = trending.data?.results || []
|
||||
const trendPool = filterToMissing(trendRaw, lib.data, hideAdult, m => !!(m as any).adult)
|
||||
.filter((m: any) => m.backdrop_path && m.overview)
|
||||
if (trendPool.length > 0) {
|
||||
return { item: trendPool[0], reason: 'trending' as const, genre: null }
|
||||
}
|
||||
return null
|
||||
}, [personalData, trending.data, lib.data, hideAdult, kind, topGenre])
|
||||
|
||||
if (!pick) return null
|
||||
|
||||
const p = pick.item as any
|
||||
const title: string = p.title || p.name || ''
|
||||
const overview: string = p.overview || ''
|
||||
const backdrop = `${TMDB_IMG}/w1280${p.backdrop_path}`
|
||||
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 mediaType: 'movie' | 'tv' = (p.media_type === 'tv' || p.first_air_date) ? 'tv' : 'movie'
|
||||
|
||||
function open() {
|
||||
navigate(`/item/tmdb-${mediaType}-${p.id}`)
|
||||
}
|
||||
|
||||
const eyebrowText = pick.reason === 'personalized'
|
||||
? `Tonight - because you watch a lot of ${pick.genre?.toLowerCase()}`
|
||||
: 'Tonight - trending picks'
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.55, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mx-7 mb-9 relative rounded-2xl overflow-hidden ring-1 ring-border bg-elevated/40 shadow-[0_24px_48px_-16px_rgba(0,0,0,0.65)]"
|
||||
style={{ aspectRatio: '21/9', maxHeight: '440px' }}
|
||||
>
|
||||
<motion.img
|
||||
key={p.id}
|
||||
initial={{ scale: 1.04, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
src={backdrop}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/82 to-void/10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-void/95 via-void/30 to-transparent" />
|
||||
<div className="absolute inset-0 noise pointer-events-none" />
|
||||
|
||||
<button
|
||||
onClick={open}
|
||||
className="absolute inset-0 z-10 group cursor-pointer focus-ring rounded-2xl"
|
||||
aria-label={`Open ${title}`}
|
||||
>
|
||||
<div className="absolute left-0 right-0 bottom-0 p-7 md:p-9 max-w-2xl text-left">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<MoonStars size={12} className="text-accent" />
|
||||
<span className="text-[10.5px] uppercase tracking-[0.2em] font-semibold text-accent">
|
||||
{eyebrowText}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-[26px] md:text-[34px] font-bold tracking-tight text-text-1 leading-[1.05] mb-2.5">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2.5 text-[12px] text-text-3 mb-3.5 tabular-nums">
|
||||
{year && <span>{year}</span>}
|
||||
{year && rating && <span className="text-text-5">·</span>}
|
||||
{rating && (
|
||||
<span className="inline-flex items-center gap-1 text-accent">
|
||||
<Star size={11} fill="currentColor" stroke={0} />
|
||||
{rating}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-5">·</span>
|
||||
<span className="uppercase tracking-[0.14em] text-[10.5px] font-semibold text-text-3">
|
||||
{mediaType === 'tv' ? 'Series' : 'Movie'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-text-2 leading-relaxed line-clamp-2 mb-5 max-w-xl">
|
||||
{overview}
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-2 h-10 px-5 rounded-full bg-accent text-void text-[13px] font-semibold tracking-tight transition-transform duration-200 group-hover:scale-[1.03] group-active:scale-[0.97] shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]">
|
||||
View details
|
||||
<ArrowRight size={14} stroke={2.25} />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user