110 lines
4.8 KiB
TypeScript
110 lines
4.8 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { motion } from 'framer-motion'
|
|
import { ArrowRight, Star } from '../../lib/icons'
|
|
import { useTmdbTrending } from '../../hooks/use-tmdb'
|
|
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
|
import { filterToMissing } from '../../pages/discover/helpers'
|
|
|
|
const TMDB_IMG = 'https://image.tmdb.org/t/p'
|
|
|
|
/**
|
|
* Editorial pick at the top of Discover. Picks the highest-ranked
|
|
* trending-today item the user doesn't already own, with a backdrop +
|
|
* synopsis available. If nothing qualifies, the hero hides itself so
|
|
* the page falls back to the chips + rows beneath.
|
|
*
|
|
* Visual: 21:9 backdrop, left-side gradient ramp to bg-void so the
|
|
* copy stays legible against any image. The DNA borrows the hero pattern
|
|
* from DetailPage's hero but in a smaller, contained card.
|
|
*/
|
|
export function SpotlightHero({ kind = 'all' }: { kind?: 'all' | 'movie' | 'tv' }) {
|
|
const navigate = useNavigate()
|
|
const trending = useTmdbTrending(kind, 'day')
|
|
const lib = useLibraryByTmdbId()
|
|
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
|
|
|
const pick = useMemo(() => {
|
|
const list = trending.data?.results || []
|
|
const filtered = filterToMissing(list, lib.data, hideAdult, m => !!m.adult)
|
|
return filtered.find(m => m.backdrop_path && m.overview) || null
|
|
}, [trending.data, lib.data, hideAdult])
|
|
|
|
if (!pick) return null
|
|
|
|
const title: string = pick.title || pick.name || 'Untitled'
|
|
const overview: string = pick.overview || ''
|
|
const backdrop = `${TMDB_IMG}/w1280${pick.backdrop_path}`
|
|
const year = (pick.release_date || pick.first_air_date || '').slice(0, 4)
|
|
const rating = typeof pick.vote_average === 'number' ? pick.vote_average.toFixed(1) : null
|
|
const mediaType: 'movie' | 'tv' = pick.media_type === 'tv' || pick.first_air_date ? 'tv' : 'movie'
|
|
|
|
function open() {
|
|
if (!pick) return
|
|
navigate(`/item/tmdb-${mediaType}-${pick.id}`)
|
|
}
|
|
|
|
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={pick.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">
|
|
<span className="w-1 h-3 rounded-full bg-accent" />
|
|
<span className="text-[10.5px] uppercase tracking-[0.2em] font-semibold text-accent">
|
|
Pick of the day
|
|
</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>
|
|
)
|
|
}
|