discover components
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronRight, X } from '../../lib/icons'
|
||||
import {
|
||||
genreVisual,
|
||||
languageVisual,
|
||||
studioVisual,
|
||||
networkVisual,
|
||||
tileBackground,
|
||||
} from '../../lib/discover-icons'
|
||||
import {
|
||||
GenreRow,
|
||||
LanguageRow,
|
||||
StudioRow,
|
||||
NetworkRow,
|
||||
} from '../../pages/discover/rows'
|
||||
import { GENRE_ROWS, LANGUAGE_ROWS } from '../../pages/discover/helpers'
|
||||
import { STUDIOS, NETWORKS } from '../../lib/studios-and-networks'
|
||||
|
||||
export type BrowseKey =
|
||||
| { kind: 'genre'; label: string; subtitle: string }
|
||||
| { kind: 'language'; code: string; label: string; subtitle: string }
|
||||
| { kind: 'studio'; id: number; label: string; subtitle: string }
|
||||
| { kind: 'network'; id: number; label: string; subtitle: string }
|
||||
|
||||
function isSameSelection(a: BrowseKey | null, b: BrowseKey | null): boolean {
|
||||
if (!a || !b) return a === b
|
||||
if (a.kind !== b.kind) return false
|
||||
if (a.kind === 'genre' && b.kind === 'genre') return a.label === b.label
|
||||
if (a.kind === 'language' && b.kind === 'language') return a.code === b.code
|
||||
if ((a.kind === 'studio' && b.kind === 'studio') || (a.kind === 'network' && b.kind === 'network')) {
|
||||
return (a as any).id === (b as any).id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* "Browse by X" section. Renders a compact grid of tiles for genres /
|
||||
* languages / studios / networks. Picking a tile expands its full row
|
||||
* underneath the grid in place - one expansion at a time across the
|
||||
* three sections, since the parent owns the state.
|
||||
*/
|
||||
export function BrowseSection({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
tiles,
|
||||
expanded,
|
||||
onSelect,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
subtitle: string
|
||||
tiles: BrowseKey[]
|
||||
expanded: BrowseKey | null
|
||||
onSelect: (key: BrowseKey | null) => void
|
||||
}) {
|
||||
const hasMine = tiles.some(t => isSameSelection(t, expanded))
|
||||
return (
|
||||
<section className="px-7 pt-6 mb-8">
|
||||
<header className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||
<span className="text-[10.5px] font-semibold text-text-3 uppercase tracking-[0.18em]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-[20px] font-bold tracking-tight text-text-1 leading-tight">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-[12px] text-text-3 mt-0.5">{subtitle}</p>
|
||||
</header>
|
||||
|
||||
<ul className="grid gap-2.5 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{tiles.map(t => {
|
||||
const active = isSameSelection(t, expanded)
|
||||
return (
|
||||
<li key={tileKey(t)}>
|
||||
<Tile tile={t} active={active} onClick={() => onSelect(active ? null : t)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{hasMine && expanded && (
|
||||
<motion.div
|
||||
key={tileKey(expanded)}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="-mx-7 mt-6 overflow-hidden"
|
||||
>
|
||||
<div className="px-7 pt-4 pb-2 flex items-center justify-between">
|
||||
<p className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-accent">
|
||||
Now showing - {labelOf(expanded)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full text-[11.5px] font-medium bg-elevated/50 text-text-2 border border-border hover:text-text-1 hover:border-border-strong transition-colors focus-ring"
|
||||
>
|
||||
<X size={11} stroke={2.25} />
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
<ExpansionContent tile={expanded} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function tileKey(t: BrowseKey): string {
|
||||
switch (t.kind) {
|
||||
case 'genre': return `g:${t.label}`
|
||||
case 'language': return `l:${t.code}`
|
||||
case 'studio': return `s:${t.id}`
|
||||
case 'network': return `n:${t.id}`
|
||||
}
|
||||
}
|
||||
|
||||
function labelOf(t: BrowseKey): string {
|
||||
return t.label
|
||||
}
|
||||
|
||||
function Tile({
|
||||
tile,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
tile: BrowseKey
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const v =
|
||||
tile.kind === 'genre' ? genreVisual(tile.label)
|
||||
: tile.kind === 'language' ? languageVisual(tile.code)
|
||||
: tile.kind === 'studio' ? studioVisual()
|
||||
: networkVisual()
|
||||
const Icon = v.icon
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||
aria-pressed={active}
|
||||
className={`group relative w-full aspect-[7/4] rounded-xl overflow-hidden border text-left focus-ring transition-colors ${
|
||||
active ? 'border-accent/55' : 'border-border hover:border-border-strong'
|
||||
}`}
|
||||
style={{ background: tileBackground(v.hue) }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-black/35 via-transparent to-transparent" />
|
||||
<Icon
|
||||
size={28}
|
||||
stroke={1.5}
|
||||
className={`absolute right-3 bottom-3 transition-all duration-200 ${
|
||||
active ? 'text-accent opacity-100 scale-105' : 'text-text-1/70 opacity-65 group-hover:opacity-90'
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 p-3.5 flex flex-col">
|
||||
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-white/55 mb-auto">
|
||||
{kindLabel(tile.kind)}
|
||||
</p>
|
||||
<p className="font-display text-[15px] font-bold tracking-tight text-text-1 leading-tight pr-8">
|
||||
{tile.label}
|
||||
</p>
|
||||
<p className="text-[10.5px] text-text-3 mt-0.5 pr-8 line-clamp-1">
|
||||
{tile.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{active && (
|
||||
<span
|
||||
className="absolute top-2 left-2 w-1.5 h-1.5 rounded-full bg-accent shadow-[0_0_6px_rgba(245,182,66,0.7)]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={`absolute top-2 right-2 text-text-3 transition-opacity ${
|
||||
active ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
/>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
function kindLabel(k: BrowseKey['kind']): string {
|
||||
switch (k) {
|
||||
case 'genre': return 'Genre'
|
||||
case 'language': return 'Language'
|
||||
case 'studio': return 'Studio'
|
||||
case 'network': return 'Network'
|
||||
}
|
||||
}
|
||||
|
||||
function ExpansionContent({ tile }: { tile: BrowseKey }) {
|
||||
switch (tile.kind) {
|
||||
case 'genre':
|
||||
return <GenreRow genre={{ label: tile.label, subtitle: tile.subtitle }} />
|
||||
case 'language':
|
||||
return <LanguageRow lang={{ code: tile.code, label: tile.label, subtitle: tile.subtitle }} />
|
||||
case 'studio':
|
||||
return <StudioRow brandId={tile.id} label={tile.label} subtitle={tile.subtitle} />
|
||||
case 'network':
|
||||
return <NetworkRow brandId={tile.id} label={tile.label} subtitle={tile.subtitle} />
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helpers that produce the tile lists for each browse section.
|
||||
*/
|
||||
|
||||
export function genreTiles(): BrowseKey[] {
|
||||
return GENRE_ROWS.map(g => ({ kind: 'genre', label: g.label, subtitle: g.subtitle }))
|
||||
}
|
||||
|
||||
export function languageTiles(): BrowseKey[] {
|
||||
return LANGUAGE_ROWS.map(l => ({ kind: 'language', code: l.code, label: l.label, subtitle: l.subtitle }))
|
||||
}
|
||||
|
||||
export function studioTiles(): BrowseKey[] {
|
||||
return STUDIOS.map(s => ({ kind: 'studio', id: s.id, label: s.label, subtitle: s.blurb || '' }))
|
||||
}
|
||||
|
||||
export function networkTiles(): BrowseKey[] {
|
||||
return NETWORKS.map(n => ({ kind: 'network', id: n.id, label: n.label, subtitle: n.blurb || '' }))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo } from 'react'
|
||||
import ContentRow from '../ui/ContentRow'
|
||||
import { useTmdbDiscoverMovies } from '../../hooks/use-tmdb'
|
||||
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
||||
import { filterToMissing } from '../../pages/discover/helpers'
|
||||
|
||||
interface CanonicalList {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
params: Record<string, string>
|
||||
/** Optional client-side filter applied on top of TMDB results. */
|
||||
extra?: (m: { original_language?: string; vote_count?: number }) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-curated approximations of the canonical "you should have seen this"
|
||||
* lists - AFI / Sight & Sound / Oscar winners - built from TMDB discover
|
||||
* parameters rather than the volatile user-created /list endpoint.
|
||||
*
|
||||
* Each entry is one ContentRow. The rows self-hide via the existing
|
||||
* filterToMissing pipeline when the user already owns everything in them.
|
||||
*/
|
||||
const LISTS: CanonicalList[] = [
|
||||
{
|
||||
id: 'best-picture-winners',
|
||||
title: 'Best Picture winners',
|
||||
subtitle: 'Academy Award winners across the decades',
|
||||
params: {
|
||||
// TMDB keyword 210024 = "academy award - best picture winner"
|
||||
with_keywords: '210024',
|
||||
sort_by: 'vote_average.desc',
|
||||
'vote_count.gte': '500',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'top-250',
|
||||
title: 'The canonical 250',
|
||||
subtitle: 'Films that have settled into the canon - massive vote count, top scores',
|
||||
params: {
|
||||
'vote_count.gte': '10000',
|
||||
'vote_average.gte': '8',
|
||||
sort_by: 'vote_average.desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'highest-grossing',
|
||||
title: 'Highest grossing of all time',
|
||||
subtitle: 'The films that made everyone show up',
|
||||
params: {
|
||||
sort_by: 'revenue.desc',
|
||||
'vote_count.gte': '500',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'modern-classics',
|
||||
title: 'Modern classics',
|
||||
subtitle: 'Post-2000 films that already feel essential',
|
||||
params: {
|
||||
'primary_release_date.gte': '2000-01-01',
|
||||
'vote_count.gte': '3000',
|
||||
'vote_average.gte': '8',
|
||||
sort_by: 'vote_average.desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'foreign-canon',
|
||||
title: 'Foreign cinema canon',
|
||||
subtitle: 'Non-English films with critical pedigree',
|
||||
params: {
|
||||
'vote_count.gte': '1500',
|
||||
'vote_average.gte': '7.8',
|
||||
sort_by: 'vote_average.desc',
|
||||
},
|
||||
extra: m => !!m.original_language && m.original_language !== 'en',
|
||||
},
|
||||
{
|
||||
id: 'animation-canon',
|
||||
title: 'Animation canon',
|
||||
subtitle: 'Highest-rated animated features across studios',
|
||||
params: {
|
||||
with_genres: '16',
|
||||
'vote_count.gte': '2000',
|
||||
'vote_average.gte': '7.5',
|
||||
sort_by: 'vote_average.desc',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Section wrapper for the canonical-lists block. Only renders on the
|
||||
* movies tab because the TMDB keywords + revenue sorts only make sense
|
||||
* for films - TV equivalents would need different queries.
|
||||
*/
|
||||
export default function CanonicalLists() {
|
||||
return (
|
||||
<section className="pt-2 pb-2">
|
||||
<div className="px-7 mb-5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||
<span className="text-[10.5px] font-semibold text-text-3 uppercase tracking-[0.18em]">
|
||||
Canonical lists
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-[20px] font-bold tracking-tight text-text-1 leading-tight">
|
||||
The books and ballots
|
||||
</h2>
|
||||
<p className="text-[12px] text-text-3 mt-0.5">
|
||||
Films that show up on every "best of" list - filtered to ones you don't own yet.
|
||||
</p>
|
||||
</div>
|
||||
{LISTS.map(list => (
|
||||
<CanonicalRow key={list.id} list={list} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CanonicalRow({ list }: { list: CanonicalList }) {
|
||||
const movies = useTmdbDiscoverMovies(list.params)
|
||||
const lib = useLibraryByTmdbId()
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const items = useMemo(() => {
|
||||
let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
if (list.extra) raw = raw.filter(list.extra as any)
|
||||
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data)
|
||||
}, [movies.data, lib.data, hideAdult, list])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={list.title}
|
||||
subtitle={list.subtitle}
|
||||
items={items}
|
||||
layoutKey={`canonical_${list.id}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useMemo } from 'react'
|
||||
import ContentRow from '../ui/ContentRow'
|
||||
import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb'
|
||||
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
||||
import { filterToMissing } from '../../pages/discover/helpers'
|
||||
|
||||
interface Decade {
|
||||
label: string
|
||||
/** Inclusive start + end years for the decade. */
|
||||
from: number
|
||||
to: number
|
||||
/** A short evocative subtitle used when this decade is expanded. */
|
||||
blurb: string
|
||||
}
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
|
||||
const DECADES: Decade[] = [
|
||||
{ label: '1950s', from: 1950, to: 1959, blurb: 'Studio system, Westerns, noir, the dawn of widescreen' },
|
||||
{ label: '1960s', from: 1960, to: 1969, blurb: 'New Wave, counterculture cinema, epic spectacle' },
|
||||
{ label: '1970s', from: 1970, to: 1979, blurb: 'New Hollywood - auteurs in charge of the asylum' },
|
||||
{ label: '1980s', from: 1980, to: 1989, blurb: 'Blockbuster era, practical effects, neon everything' },
|
||||
{ label: '1990s', from: 1990, to: 1999, blurb: 'Indie boom, CGI takes over, prestige cable begins' },
|
||||
{ label: '2000s', from: 2000, to: 2009, blurb: 'Digital filmmaking, fantasy trilogies, mumblecore' },
|
||||
{ label: '2010s', from: 2010, to: 2019, blurb: 'Streaming wars, MCU dominance, peak TV' },
|
||||
{ label: '2020s', from: 2020, to: Math.max(CURRENT_YEAR, 2024), blurb: 'Post-streaming reshuffle, A24 era, global cinema' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
kind: 'movie' | 'tv'
|
||||
active: string | null
|
||||
onChange: (decadeLabel: string | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-based discovery surface. A horizontal strip of decades sits at
|
||||
* the top; click one to expand a row of the highest-rated titles from
|
||||
* that era. Different mental model from mood / genre - the user comes
|
||||
* in thinking "something from the 80s" and lands directly on it.
|
||||
*/
|
||||
export default function DecadeStrip({ kind, active, onChange }: Props) {
|
||||
return (
|
||||
<section className="px-7 mb-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-3 rounded-full bg-accent" />
|
||||
<span className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-text-3">
|
||||
By decade
|
||||
</span>
|
||||
</div>
|
||||
<div className="-mx-7 px-7 overflow-x-auto hide-scrollbar">
|
||||
<ul className="flex items-stretch gap-2 min-w-max">
|
||||
{DECADES.map(d => (
|
||||
<DecadeChip
|
||||
key={d.label}
|
||||
decade={d}
|
||||
active={active === d.label}
|
||||
onClick={() => onChange(active === d.label ? null : d.label)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{active && (
|
||||
<motion.div
|
||||
key={active + kind}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="-mx-7 mt-4"
|
||||
>
|
||||
<DecadeRow
|
||||
decade={DECADES.find(d => d.label === active)!}
|
||||
kind={kind}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DecadeChip({
|
||||
decade,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
decade: Decade
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const startsAt = decade.from
|
||||
const isFuture = decade.to > CURRENT_YEAR - 1 && decade.from <= CURRENT_YEAR
|
||||
return (
|
||||
<li>
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
aria-pressed={active}
|
||||
className={`relative inline-flex flex-col items-start h-[68px] w-[112px] px-3.5 py-2.5 rounded-xl border text-left transition-colors duration-200 focus-ring ${
|
||||
active
|
||||
? 'bg-accent text-void border-accent shadow-[0_10px_24px_-10px_rgba(245,182,66,0.55)]'
|
||||
: 'bg-elevated/50 text-text-2 border-border hover:border-border-strong hover:text-text-1 hover:bg-elevated/80'
|
||||
}`}
|
||||
>
|
||||
<span className={`font-display text-[18px] font-bold leading-none tabular-nums ${
|
||||
active ? 'text-void' : 'text-text-1'
|
||||
}`}>
|
||||
{decade.label}
|
||||
</span>
|
||||
<span className={`mt-auto text-[10px] uppercase tracking-[0.14em] font-semibold ${
|
||||
active ? 'text-void/65' : 'text-text-4'
|
||||
}`}>
|
||||
{isFuture ? `${startsAt}-now` : `${startsAt}-${decade.to}`}
|
||||
</span>
|
||||
</motion.button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function DecadeRow({ decade, kind }: { decade: Decade; kind: 'movie' | 'tv' }) {
|
||||
const lib = useLibraryByTmdbId()
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
// The TMDB discover endpoints accept primary_release_year / first_air_date
|
||||
// ranges. We can use the .gte / .lte form to bracket the decade.
|
||||
const dateField = kind === 'movie' ? 'primary_release_date' : 'first_air_date'
|
||||
const params: Record<string, string> = {
|
||||
[`${dateField}.gte`]: `${decade.from}-01-01`,
|
||||
[`${dateField}.lte`]: `${decade.to}-12-31`,
|
||||
'vote_count.gte': '500',
|
||||
'vote_average.gte': '7',
|
||||
sort_by: 'vote_average.desc',
|
||||
}
|
||||
|
||||
const movieQuery = useTmdbDiscoverMovies(kind === 'movie' ? params : {})
|
||||
const tvQuery = useTmdbDiscoverTv(kind === 'tv' ? params : {})
|
||||
const data = kind === 'movie' ? movieQuery.data : tvQuery.data
|
||||
|
||||
const items = useMemo(() => {
|
||||
const raw = (data?.results || []).map(m => ({ ...m, media_type: kind }))
|
||||
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data)
|
||||
}, [data, lib.data, hideAdult, kind])
|
||||
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={`The ${decade.label}`}
|
||||
subtitle={decade.blurb}
|
||||
items={items}
|
||||
layoutKey={`decade_${decade.label}_${kind}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Filter,
|
||||
Tv2,
|
||||
Film as FilmIcon,
|
||||
RefreshCw,
|
||||
Star,
|
||||
Clock,
|
||||
Calendar,
|
||||
Languages,
|
||||
Activity,
|
||||
Boxes,
|
||||
ChevronUp,
|
||||
Library,
|
||||
} from '../../lib/icons'
|
||||
import { TMDB_MOVIE_GENRES, TMDB_TV_GENRES } from '../../lib/tmdb-genres'
|
||||
import { Chip } from '../ui/Chip'
|
||||
import Select, { type SelectOption } from '../ui/Select'
|
||||
|
||||
export interface DiscoverFilterState {
|
||||
kind: 'movie' | 'tv'
|
||||
sortBy: string
|
||||
genres: number[]
|
||||
yearFrom: number | null
|
||||
yearTo: number | null
|
||||
voteAverageGte: number | null
|
||||
voteCountGte: number | null
|
||||
runtimeGte: number | null
|
||||
runtimeLte: number | null
|
||||
language: string | null
|
||||
watchProviders: number[]
|
||||
watchRegion: string | null
|
||||
status: string | null
|
||||
hideOwned: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: DiscoverFilterState = {
|
||||
kind: 'movie',
|
||||
sortBy: 'popularity.desc',
|
||||
genres: [],
|
||||
yearFrom: null,
|
||||
yearTo: null,
|
||||
voteAverageGte: null,
|
||||
voteCountGte: null,
|
||||
runtimeGte: null,
|
||||
runtimeLte: null,
|
||||
language: null,
|
||||
watchProviders: [],
|
||||
watchRegion: null,
|
||||
status: null,
|
||||
hideOwned: true,
|
||||
}
|
||||
|
||||
const MOVIE_SORTS: SelectOption[] = [
|
||||
{ value: 'popularity.desc', label: 'Popularity (high to low)' },
|
||||
{ value: 'popularity.asc', label: 'Popularity (low to high)' },
|
||||
{ value: 'vote_average.desc', label: 'Rating (high to low)' },
|
||||
{ value: 'vote_average.asc', label: 'Rating (low to high)' },
|
||||
{ value: 'primary_release_date.desc', label: 'Release date (newest)' },
|
||||
{ value: 'primary_release_date.asc', label: 'Release date (oldest)' },
|
||||
{ value: 'revenue.desc', label: 'Revenue (high to low)' },
|
||||
{ value: 'original_title.asc', label: 'Title (A-Z)' },
|
||||
{ value: 'original_title.desc', label: 'Title (Z-A)' },
|
||||
]
|
||||
|
||||
const TV_SORTS: SelectOption[] = [
|
||||
{ value: 'popularity.desc', label: 'Popularity (high to low)' },
|
||||
{ value: 'popularity.asc', label: 'Popularity (low to high)' },
|
||||
{ value: 'vote_average.desc', label: 'Rating (high to low)' },
|
||||
{ value: 'vote_average.asc', label: 'Rating (low to high)' },
|
||||
{ value: 'first_air_date.desc', label: 'First air date (newest)' },
|
||||
{ value: 'first_air_date.asc', label: 'First air date (oldest)' },
|
||||
{ value: 'name.asc', label: 'Title (A-Z)' },
|
||||
{ value: 'name.desc', label: 'Title (Z-A)' },
|
||||
]
|
||||
|
||||
// Radix Select rejects empty-string values, so the "any" sentinel is a
|
||||
// magic string we translate to null at the API boundary.
|
||||
const ANY = '__any__'
|
||||
|
||||
const TV_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ value: ANY, label: 'Any status' },
|
||||
{ value: '0', label: 'Returning Series' },
|
||||
{ value: '1', label: 'Planned' },
|
||||
{ value: '2', label: 'In Production' },
|
||||
{ value: '3', label: 'Ended' },
|
||||
{ value: '4', label: 'Canceled' },
|
||||
{ value: '5', label: 'Pilot' },
|
||||
]
|
||||
|
||||
const LANGUAGES: SelectOption[] = [
|
||||
{ value: ANY, label: 'Any language' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'ja', label: 'Japanese' },
|
||||
{ value: 'ko', label: 'Korean' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
{ value: 'de', label: 'German' },
|
||||
{ value: 'it', label: 'Italian' },
|
||||
{ value: 'zh', label: 'Chinese' },
|
||||
{ value: 'hi', label: 'Hindi' },
|
||||
{ value: 'pt', label: 'Portuguese' },
|
||||
{ value: 'ru', label: 'Russian' },
|
||||
{ value: 'sv', label: 'Swedish' },
|
||||
{ value: 'da', label: 'Danish' },
|
||||
{ value: 'nl', label: 'Dutch' },
|
||||
{ value: 'pl', label: 'Polish' },
|
||||
{ value: 'tr', label: 'Turkish' },
|
||||
{ value: 'ar', label: 'Arabic' },
|
||||
{ value: 'th', label: 'Thai' },
|
||||
]
|
||||
|
||||
const VOTE_COUNT_OPTIONS: SelectOption[] = [
|
||||
{ value: '0', label: 'Any number of votes' },
|
||||
{ value: '50', label: 'At least 50 votes' },
|
||||
{ value: '200', label: 'At least 200 votes' },
|
||||
{ value: '500', label: 'At least 500 votes' },
|
||||
{ value: '1000', label: 'At least 1,000 votes' },
|
||||
{ value: '5000', label: 'At least 5,000 votes' },
|
||||
{ value: '10000', label: 'At least 10,000 votes' },
|
||||
]
|
||||
|
||||
const COMMON_PROVIDERS: Array<{ id: number; label: string }> = [
|
||||
{ id: 8, label: 'Netflix' },
|
||||
{ id: 9, label: 'Prime Video' },
|
||||
{ id: 337, label: 'Disney+' },
|
||||
{ id: 1899, label: 'Max' },
|
||||
{ id: 350, label: 'Apple TV+' },
|
||||
{ id: 15, label: 'Hulu' },
|
||||
{ id: 531, label: 'Paramount+' },
|
||||
{ id: 386, label: 'Peacock' },
|
||||
{ id: 283, label: 'Crunchyroll' },
|
||||
{ id: 2, label: 'Apple TV' },
|
||||
{ id: 192, label: 'YouTube' },
|
||||
{ id: 3, label: 'Google Play' },
|
||||
{ id: 68, label: 'Microsoft' },
|
||||
]
|
||||
|
||||
export function countActiveFilters(f: DiscoverFilterState): number {
|
||||
let n = 0
|
||||
if (f.genres.length > 0) n++
|
||||
if (f.yearFrom != null || f.yearTo != null) n++
|
||||
if (f.voteAverageGte != null) n++
|
||||
if (f.voteCountGte != null) n++
|
||||
if (f.runtimeGte != null || f.runtimeLte != null) n++
|
||||
if (f.language) n++
|
||||
if (f.watchProviders.length > 0) n++
|
||||
if (f.status) n++
|
||||
if (f.sortBy !== DEFAULT_FILTERS.sortBy) n++
|
||||
return n
|
||||
}
|
||||
|
||||
export function hasAnyActiveFilters(f: DiscoverFilterState): boolean {
|
||||
return countActiveFilters(f) > 0
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filters: DiscoverFilterState
|
||||
onChange: (next: DiscoverFilterState) => void
|
||||
region: string
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* Top toolbar - kind toggle + sort + filter button + hide-owned */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function DiscoverFilters({ filters, onChange, region }: Props) {
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
const activeCount = countActiveFilters(filters)
|
||||
const sorts = filters.kind === 'movie' ? MOVIE_SORTS : TV_SORTS
|
||||
|
||||
const set = <K extends keyof DiscoverFilterState>(k: K, v: DiscoverFilterState[K]) => {
|
||||
onChange({ ...filters, [k]: v })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-7">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<KindToggle
|
||||
value={filters.kind}
|
||||
onChange={k => onChange({ ...filters, kind: k, status: null, runtimeGte: null, runtimeLte: null })}
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-border mx-1" />
|
||||
|
||||
<Select
|
||||
ariaLabel="Sort by"
|
||||
size="sm"
|
||||
width="min-w-[220px]"
|
||||
value={filters.sortBy}
|
||||
onChange={v => set('sortBy', v)}
|
||||
options={sorts}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
as="button"
|
||||
size="md"
|
||||
tone={activeCount > 0 ? 'accent' : 'outline'}
|
||||
icon={<Filter size={12} stroke={2} />}
|
||||
trailing={activeCount > 0 ? (
|
||||
<span className="inline-flex items-center justify-center min-w-[16px] h-[16px] px-1 rounded-full bg-accent text-void text-[10px] font-bold tabular-nums">
|
||||
{activeCount}
|
||||
</span>
|
||||
) : null}
|
||||
onClick={() => setPanelOpen(o => !o)}
|
||||
>
|
||||
Filters
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
as="button"
|
||||
size="md"
|
||||
tone={filters.hideOwned ? 'accent' : 'outline'}
|
||||
icon={<Library size={12} stroke={2} />}
|
||||
onClick={() => set('hideOwned', !filters.hideOwned)}
|
||||
>
|
||||
Hide items in your library
|
||||
</Chip>
|
||||
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
onClick={() => onChange({
|
||||
...DEFAULT_FILTERS,
|
||||
kind: filters.kind,
|
||||
hideOwned: filters.hideOwned,
|
||||
})}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md text-[11.5px] tracking-tight text-text-3 hover:text-text-1 transition focus-ring"
|
||||
>
|
||||
<RefreshCw size={11} stroke={2} />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
<AnimatePresence>
|
||||
{panelOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 p-6 rounded-xl bg-elevated/40 border border-border">
|
||||
<FilterPanel filters={filters} onChange={onChange} region={region} />
|
||||
<div className="mt-5 pt-4 border-t border-border/60 flex justify-end">
|
||||
<button
|
||||
onClick={() => setPanelOpen(false)}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-[11.5px] tracking-tight text-text-3 hover:text-text-1 transition focus-ring"
|
||||
>
|
||||
<ChevronUp size={11} stroke={2} />
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KindToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: 'movie' | 'tv'
|
||||
onChange: (k: 'movie' | 'tv') => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex p-0.5 bg-void/60 rounded-md border border-border">
|
||||
{([
|
||||
{ v: 'movie' as const, label: 'Movies', Icon: FilmIcon },
|
||||
{ v: 'tv' as const, label: 'TV', Icon: Tv2 },
|
||||
]).map(opt => {
|
||||
const isActive = value === opt.v
|
||||
return (
|
||||
<button
|
||||
key={opt.v}
|
||||
onClick={() => onChange(opt.v)}
|
||||
className={`relative h-7 px-3 text-[11.5px] font-medium tracking-tight transition-colors duration-150 rounded focus-ring inline-flex items-center gap-1.5 ${
|
||||
isActive ? 'text-void' : 'text-text-3 hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="discover-kind-active"
|
||||
className="absolute inset-0 bg-accent rounded"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center gap-1.5">
|
||||
<opt.Icon size={12} stroke={2.25} />
|
||||
{opt.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* Expandable filter panel */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function FilterPanel({ filters, onChange, region }: Props) {
|
||||
const set = <K extends keyof DiscoverFilterState>(k: K, v: DiscoverFilterState[K]) => {
|
||||
onChange({ ...filters, [k]: v })
|
||||
}
|
||||
|
||||
const genres = useMemo(() => {
|
||||
const map = filters.kind === 'movie' ? TMDB_MOVIE_GENRES : TMDB_TV_GENRES
|
||||
const seen = new Set<number>()
|
||||
return Object.entries(map).flatMap(([label, id]) => {
|
||||
if (seen.has(id)) return []
|
||||
seen.add(id)
|
||||
return [{ label, id }]
|
||||
})
|
||||
}, [filters.kind])
|
||||
|
||||
function toggleGenre(id: number) {
|
||||
const next = filters.genres.includes(id)
|
||||
? filters.genres.filter(g => g !== id)
|
||||
: [...filters.genres, id]
|
||||
set('genres', next)
|
||||
}
|
||||
|
||||
function toggleProvider(id: number) {
|
||||
const next = filters.watchProviders.includes(id)
|
||||
? filters.watchProviders.filter(p => p !== id)
|
||||
: [...filters.watchProviders, id]
|
||||
set('watchProviders', next)
|
||||
if (next.length > 0 && !filters.watchRegion) {
|
||||
set('watchRegion', region)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
{/* Genres - always full row, can be many */}
|
||||
<Field
|
||||
label="Genres"
|
||||
icon={<Boxes size={11} stroke={2} />}
|
||||
value={filters.genres.length > 0 ? `${filters.genres.length} selected` : 'Any'}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{genres.map(g => (
|
||||
<Chip
|
||||
as="button"
|
||||
key={g.id}
|
||||
size="sm"
|
||||
tone={filters.genres.includes(g.id) ? 'accent' : 'outline'}
|
||||
onClick={() => toggleGenre(g.id)}
|
||||
>
|
||||
{g.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Streaming providers - always full row */}
|
||||
<Field
|
||||
label="Streaming services"
|
||||
icon={<Tv2 size={11} stroke={2} />}
|
||||
value={
|
||||
filters.watchProviders.length > 0
|
||||
? `${filters.watchProviders.length} in ${filters.watchRegion || region}`
|
||||
: 'Any'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{COMMON_PROVIDERS.map(p => (
|
||||
<Chip
|
||||
as="button"
|
||||
key={p.id}
|
||||
size="sm"
|
||||
tone={filters.watchProviders.includes(p.id) ? 'accent' : 'outline'}
|
||||
onClick={() => toggleProvider(p.id)}
|
||||
>
|
||||
{p.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Two-column grid for the smaller fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-7">
|
||||
<Field
|
||||
label={filters.kind === 'movie' ? 'Release year' : 'First air year'}
|
||||
icon={<Calendar size={11} stroke={2} />}
|
||||
value={
|
||||
filters.yearFrom || filters.yearTo
|
||||
? `${filters.yearFrom ?? '...'} to ${filters.yearTo ?? '...'}`
|
||||
: 'Any year'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<YearInput
|
||||
value={filters.yearFrom}
|
||||
onChange={v => set('yearFrom', v)}
|
||||
placeholder="From"
|
||||
/>
|
||||
<span className="text-text-4 text-[12px]">to</span>
|
||||
<YearInput
|
||||
value={filters.yearTo}
|
||||
onChange={v => set('yearTo', v)}
|
||||
placeholder="To"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Minimum rating"
|
||||
icon={<Star size={11} stroke={2} />}
|
||||
value={filters.voteAverageGte != null ? `${filters.voteAverageGte} / 10` : 'Any'}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={filters.voteAverageGte ?? 0}
|
||||
onChange={e => set('voteAverageGte', Number(e.target.value) === 0 ? null : Number(e.target.value))}
|
||||
aria-label="Minimum rating"
|
||||
className="slider flex-1"
|
||||
/>
|
||||
<span className="inline-flex items-center justify-center min-w-[44px] h-7 px-2 rounded-md bg-void/60 border border-border text-[11.5px] text-text-1 font-medium tabular-nums">
|
||||
{filters.voteAverageGte ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Vote count"
|
||||
icon={<Activity size={11} stroke={2} />}
|
||||
value={
|
||||
filters.voteCountGte != null
|
||||
? `${filters.voteCountGte.toLocaleString()}+`
|
||||
: 'Any'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
ariaLabel="Minimum vote count"
|
||||
size="sm"
|
||||
value={String(filters.voteCountGte ?? 0)}
|
||||
onChange={v => set('voteCountGte', Number(v) === 0 ? null : Number(v))}
|
||||
options={VOTE_COUNT_OPTIONS}
|
||||
width="w-full"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Original language"
|
||||
icon={<Languages size={11} stroke={2} />}
|
||||
value={LANGUAGES.find(l => l.value === filters.language)?.label as string || 'Any'}
|
||||
>
|
||||
<Select
|
||||
ariaLabel="Original language"
|
||||
size="sm"
|
||||
value={filters.language ?? ANY}
|
||||
onChange={v => set('language', v === ANY ? null : v)}
|
||||
options={LANGUAGES}
|
||||
width="w-full"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{filters.kind === 'movie' && (
|
||||
<Field
|
||||
label="Runtime"
|
||||
icon={<Clock size={11} stroke={2} />}
|
||||
value={`${filters.runtimeGte ?? 0} - ${filters.runtimeLte ?? 240}m`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10.5px] text-text-4 tabular-nums w-8 text-right">
|
||||
{filters.runtimeGte ?? 0}m
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={240}
|
||||
step={10}
|
||||
value={filters.runtimeGte ?? 0}
|
||||
onChange={e => set('runtimeGte', Number(e.target.value) === 0 ? null : Number(e.target.value))}
|
||||
aria-label="Minimum runtime"
|
||||
className="slider flex-1"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={240}
|
||||
step={10}
|
||||
value={filters.runtimeLte ?? 240}
|
||||
onChange={e => set('runtimeLte', Number(e.target.value) === 240 ? null : Number(e.target.value))}
|
||||
aria-label="Maximum runtime"
|
||||
className="slider flex-1"
|
||||
/>
|
||||
<span className="text-[10.5px] text-text-4 tabular-nums w-10">
|
||||
{filters.runtimeLte ?? 240}m
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{filters.kind === 'tv' && (
|
||||
<Field
|
||||
label="Series status"
|
||||
icon={<Activity size={11} stroke={2} />}
|
||||
value={TV_STATUS_OPTIONS.find(o => o.value === filters.status)?.label as string || 'Any'}
|
||||
>
|
||||
<Select
|
||||
ariaLabel="Series status"
|
||||
size="sm"
|
||||
value={filters.status ?? ANY}
|
||||
onChange={v => set('status', v === ANY ? null : v)}
|
||||
options={TV_STATUS_OPTIONS}
|
||||
width="w-full"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
value?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-3 mb-2.5">
|
||||
<p className="text-[10.5px] font-semibold text-text-2 uppercase tracking-[0.16em] inline-flex items-center gap-1.5">
|
||||
{icon && <span className="text-text-3">{icon}</span>}
|
||||
{label}
|
||||
</p>
|
||||
{value && (
|
||||
<p className="text-[11px] text-text-4 tracking-tight tabular-nums truncate max-w-[60%]">
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function YearInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: number | null
|
||||
onChange: (v: number | null) => void
|
||||
placeholder: string
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={value ?? ''}
|
||||
onChange={e => {
|
||||
const n = Number(e.target.value)
|
||||
onChange(e.target.value === '' || Number.isNaN(n) ? null : n)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 h-9 px-3 rounded-md bg-void/50 hover:bg-void/70 ring-1 ring-border focus:ring-accent/50 focus:ring-2 outline-none text-[12.5px] tabular-nums text-text-1 placeholder:text-text-4 transition-all duration-150 font-mono"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Convert filter state to TMDB discover query params. */
|
||||
export function filtersToTmdbParams(f: DiscoverFilterState): Record<string, string> {
|
||||
const p: Record<string, string> = { sort_by: f.sortBy }
|
||||
if (f.genres.length > 0) p.with_genres = f.genres.join(',')
|
||||
if (f.yearFrom != null) {
|
||||
p[f.kind === 'movie' ? 'primary_release_date.gte' : 'first_air_date.gte'] = `${f.yearFrom}-01-01`
|
||||
}
|
||||
if (f.yearTo != null) {
|
||||
p[f.kind === 'movie' ? 'primary_release_date.lte' : 'first_air_date.lte'] = `${f.yearTo}-12-31`
|
||||
}
|
||||
if (f.voteAverageGte != null) p['vote_average.gte'] = String(f.voteAverageGte)
|
||||
if (f.voteCountGte != null) p['vote_count.gte'] = String(f.voteCountGte)
|
||||
if (f.runtimeGte != null) p['with_runtime.gte'] = String(f.runtimeGte)
|
||||
if (f.runtimeLte != null) p['with_runtime.lte'] = String(f.runtimeLte)
|
||||
if (f.language) p.with_original_language = f.language
|
||||
if (f.watchProviders.length > 0) {
|
||||
p.with_watch_providers = f.watchProviders.join('|')
|
||||
if (f.watchRegion) p.watch_region = f.watchRegion
|
||||
}
|
||||
if (f.status && f.kind === 'tv') p.with_status = f.status
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLibraryGenreDistribution, useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
||||
import { useTmdbDiscoverMovies, useTmdbTopRatedMovies } from '../../hooks/use-tmdb'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
||||
import { tmdbMovieGenreId } from '../../lib/tmdb-genres'
|
||||
import { filterToMissing } from '../../pages/discover/helpers'
|
||||
import ContentRow from '../ui/ContentRow'
|
||||
|
||||
/**
|
||||
* Set of "interesting" genres we consider when looking for library
|
||||
* gaps. Limited to the ones that have a clear TMDB equivalent and are
|
||||
* reasonable to recommend in - "Music" or "TV Movie" are excluded
|
||||
* because suggestions there are usually noise.
|
||||
*/
|
||||
const INTERESTING_GENRES = [
|
||||
'Documentary',
|
||||
'Animation',
|
||||
'Horror',
|
||||
'Romance',
|
||||
'Thriller',
|
||||
'Science Fiction',
|
||||
'Mystery',
|
||||
'Fantasy',
|
||||
'Drama',
|
||||
'Comedy',
|
||||
'Action',
|
||||
'Adventure',
|
||||
'Crime',
|
||||
'Family',
|
||||
'War',
|
||||
'Western',
|
||||
'History',
|
||||
]
|
||||
|
||||
/**
|
||||
* "Your library is heavy on X, light on Y" finder. Computes the genre
|
||||
* distribution across the user's Movie + Series catalogue, picks the
|
||||
* underrepresented INTERESTING_GENRES (definition: < 30% of the
|
||||
* top-genre count AND fewer than 12 absolute items), and surfaces a
|
||||
* top-rated TMDB row for each one.
|
||||
*
|
||||
* Hides itself when:
|
||||
* - The user has fewer than 30 items total (results would be noise)
|
||||
* - No genre crosses the under-representation threshold
|
||||
*/
|
||||
export default function LibraryGapFinder() {
|
||||
const distQuery = useLibraryGenreDistribution()
|
||||
const lib = useLibraryByTmdbId()
|
||||
|
||||
const gaps = useMemo(() => {
|
||||
const data = distQuery.data
|
||||
if (!data || data.total < 30) return [] as Array<{ genre: string; count: number; top: number }>
|
||||
const top = Math.max(...Array.from(data.counts.values()), 1)
|
||||
return INTERESTING_GENRES
|
||||
.map(g => {
|
||||
const count = data.counts.get(g) || 0
|
||||
return { genre: g, count, top }
|
||||
})
|
||||
.filter(g => g.count < 12 && g.count < top * 0.3)
|
||||
// Most-glaring gaps first (the ones with the biggest delta from top).
|
||||
.sort((a, b) => a.count - b.count)
|
||||
.slice(0, 3)
|
||||
}, [distQuery.data])
|
||||
|
||||
if (gaps.length === 0) return null
|
||||
|
||||
const data = distQuery.data!
|
||||
const topEntry = [...data.counts.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
const topGenre = topEntry ? topEntry[0] : null
|
||||
|
||||
return (
|
||||
<section className="pt-2 pb-2">
|
||||
<div className="px-7 mb-5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||
<span className="text-[10.5px] font-semibold text-text-3 uppercase tracking-[0.18em]">
|
||||
Library gaps
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-[20px] font-bold tracking-tight text-text-1 leading-tight">
|
||||
What your shelves are missing
|
||||
</h2>
|
||||
<p className="text-[12px] text-text-3 mt-0.5 max-w-2xl">
|
||||
You have plenty of <span className="text-text-2 font-medium">{topGenre || '...'}</span>{' '}
|
||||
but barely anything in {gaps.map((g, i) => (
|
||||
<span key={g.genre}>
|
||||
{i > 0 && (i === gaps.length - 1 ? ' or ' : ', ')}
|
||||
<span className="text-text-2 font-medium">{g.genre.toLowerCase()}</span>
|
||||
<span className="text-text-4 tabular-nums"> ({g.count})</span>
|
||||
</span>
|
||||
))}. A few top-rated picks worth adding:
|
||||
</p>
|
||||
</div>
|
||||
{gaps.map(g => (
|
||||
<GapRow key={g.genre} genre={g.genre} libraryMap={lib.data} />
|
||||
))}
|
||||
<CuratedGapRow
|
||||
title="IMDb Top 250"
|
||||
subtitle="Highest-rated films of all time you don't have"
|
||||
params={{
|
||||
sort_by: 'vote_average.desc',
|
||||
'vote_count.gte': '5000',
|
||||
'vote_average.gte': '8.0',
|
||||
page: '1',
|
||||
}}
|
||||
libraryMap={lib.data}
|
||||
/>
|
||||
<CuratedGapRow
|
||||
title="A24"
|
||||
subtitle="Essential films from the acclaimed studio"
|
||||
params={{
|
||||
with_companies: A24_COMPANY_ID,
|
||||
sort_by: 'vote_average.desc',
|
||||
'vote_count.gte': '100',
|
||||
page: '1',
|
||||
}}
|
||||
libraryMap={lib.data}
|
||||
/>
|
||||
<CuratedGapRow
|
||||
title="Oscar Best Picture"
|
||||
subtitle="Academy Award winners worth owning"
|
||||
params={{
|
||||
with_keywords: OSCAR_KEYWORD_ID,
|
||||
sort_by: 'vote_average.desc',
|
||||
'vote_count.gte': '500',
|
||||
'vote_average.gte': '7.0',
|
||||
page: '1',
|
||||
}}
|
||||
libraryMap={lib.data}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const A24_COMPANY_ID = '41077'
|
||||
const OSCAR_KEYWORD_ID = '10271' // Academy Awards
|
||||
|
||||
function CuratedGapRow({
|
||||
title,
|
||||
subtitle,
|
||||
params,
|
||||
libraryMap,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
params: Record<string, string>
|
||||
libraryMap?: Map<string, { id: string; name: string; type: string }>
|
||||
}) {
|
||||
const movies = useTmdbDiscoverMovies(params)
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const items = useMemo(() => {
|
||||
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap)
|
||||
}, [movies.data, libraryMap, hideAdult])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
items={items}
|
||||
layoutKey={`gap_${title}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GapRow({
|
||||
genre,
|
||||
libraryMap,
|
||||
}: {
|
||||
genre: string
|
||||
libraryMap?: Map<string, { id: string; name: string; type: string }>
|
||||
}) {
|
||||
const genreId = tmdbMovieGenreId(genre)
|
||||
const params = genreId
|
||||
? {
|
||||
with_genres: String(genreId),
|
||||
'vote_count.gte': '1000',
|
||||
'vote_average.gte': '7.2',
|
||||
sort_by: 'vote_average.desc',
|
||||
}
|
||||
: ({} as Record<string, string>)
|
||||
const movies = useTmdbDiscoverMovies(params)
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const items = useMemo(() => {
|
||||
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap)
|
||||
}, [movies.data, libraryMap, hideAdult])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={`Try some ${genre.toLowerCase()}`}
|
||||
subtitle={`Top-rated ${genre.toLowerCase()} films you don't have`}
|
||||
items={items}
|
||||
layoutKey={`gap_${genre}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb'
|
||||
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
||||
import { DISCOVER_MOODS, type DiscoverMood } from '../../lib/discover-moods'
|
||||
import { filterToMissing } from '../../pages/discover/helpers'
|
||||
import ContentRow from '../ui/ContentRow'
|
||||
|
||||
interface Props {
|
||||
activeId: string | null
|
||||
onChange: (id: string | null) => void
|
||||
kind: 'movie' | 'tv'
|
||||
}
|
||||
|
||||
export function MoodChips({ activeId, onChange, kind }: Props) {
|
||||
return (
|
||||
<div className="px-7 mb-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-3 rounded-full bg-accent" />
|
||||
<span className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-text-3">
|
||||
In the mood for
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative -mx-7 px-7 overflow-x-auto hide-scrollbar">
|
||||
<ul className="flex items-center gap-2 pb-1 min-w-max">
|
||||
{DISCOVER_MOODS.map(mood => {
|
||||
const available = kind === 'movie' ? !!mood.movieParams : !!mood.tvParams
|
||||
return (
|
||||
<MoodChipButton
|
||||
key={mood.id}
|
||||
mood={mood}
|
||||
active={activeId === mood.id && available}
|
||||
disabled={!available}
|
||||
onClick={() => available && onChange(activeId === mood.id ? null : mood.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MoodChipButton({
|
||||
mood,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
mood: DiscoverMood
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const Icon = mood.icon
|
||||
return (
|
||||
<li>
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileTap={disabled ? undefined : { scale: 0.96 }}
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled}
|
||||
title={disabled ? `${mood.label} - movies only` : mood.blurb}
|
||||
className={`relative inline-flex items-center gap-2 h-10 pl-3 pr-4 rounded-full border text-[12.5px] font-medium tracking-tight transition-colors duration-200 focus-ring ${
|
||||
active
|
||||
? 'bg-accent text-void border-accent shadow-[0_8px_20px_-8px_rgba(245,182,66,0.55)]'
|
||||
: disabled
|
||||
? 'bg-elevated/20 text-text-4 border-border/60 cursor-not-allowed'
|
||||
: 'bg-elevated/50 text-text-2 border-border hover:border-border-strong hover:text-text-1 hover:bg-elevated/80'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
stroke={active ? 2.2 : 1.75}
|
||||
className={active ? 'text-void' : disabled ? 'text-text-5' : 'text-text-2'}
|
||||
/>
|
||||
{mood.label}
|
||||
</motion.button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row that materialises beneath the chip strip when a mood is picked.
|
||||
* Movie-only moods show just one row; the few that have TV recipes show
|
||||
* two rows (movies then shows).
|
||||
*/
|
||||
export function MoodRow({ moodId, kind }: { moodId: string; kind: 'movie' | 'tv' }) {
|
||||
const mood = DISCOVER_MOODS.find(m => m.id === moodId)
|
||||
if (!mood) return null
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={moodId + ':' + kind}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{kind === 'movie' && <MoodMovieRow mood={mood} />}
|
||||
{kind === 'tv' && mood.tvParams && <MoodTvRow mood={mood} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function MoodMovieRow({ mood }: { mood: DiscoverMood }) {
|
||||
const movies = useTmdbDiscoverMovies(mood.movieParams)
|
||||
const lib = useLibraryByTmdbId()
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const items = useMemo(() => {
|
||||
let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
if (mood.extra) raw = raw.filter(mood.extra)
|
||||
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data)
|
||||
}, [movies.data, lib.data, hideAdult, mood])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={mood.label}
|
||||
subtitle={mood.blurb}
|
||||
items={items}
|
||||
layoutKey={`mood_${mood.id}_movie`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MoodTvRow({ mood }: { mood: DiscoverMood }) {
|
||||
const tv = useTmdbDiscoverTv(mood.tvParams || {})
|
||||
const lib = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const raw = (tv.data?.results || []).map(m => ({ ...m, media_type: 'tv' }))
|
||||
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
|
||||
}, [tv.data, lib.data])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={mood.label}
|
||||
subtitle={mood.blurb}
|
||||
items={items}
|
||||
layoutKey={`mood_${mood.id}_tv`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CalendarEvent, Star } from '../../lib/icons'
|
||||
import { discoverMovies, type TmdbMovie } from '../../api/tmdb'
|
||||
|
||||
const TMDB_IMG = 'https://image.tmdb.org/t/p'
|
||||
const YEARS_BACK = [5, 10, 15, 20, 25, 30, 40, 50]
|
||||
|
||||
/**
|
||||
* "On this day in cinema" rail. For a handful of anniversary windows
|
||||
* (5 / 10 / 15... years ago this week), pulls the top-rated film TMDB
|
||||
* recorded as released near today's date that year, and renders each as
|
||||
* a horizontal card. Self-hides every year-slot with no qualifying
|
||||
* release, and hides the whole rail if nothing qualifies anywhere.
|
||||
*
|
||||
* Uses a +/- 3-day window rather than exact MM-DD because exact-date
|
||||
* matches are rare - "this week in cinema history" reads naturally and
|
||||
* surfaces enough material to be interesting.
|
||||
*/
|
||||
export default function OnThisDay() {
|
||||
const navigate = useNavigate()
|
||||
const today = useMemo(() => new Date(), [])
|
||||
|
||||
const queries = useMemo(() => {
|
||||
const y = today.getFullYear()
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const d = today.getDate()
|
||||
const fromD = String(Math.max(1, d - 3)).padStart(2, '0')
|
||||
// Clamp upper bound to 28 to avoid edge cases in shorter months.
|
||||
const toD = String(Math.min(28, d + 3)).padStart(2, '0')
|
||||
return YEARS_BACK.map(yearsAgo => {
|
||||
const year = y - yearsAgo
|
||||
return {
|
||||
yearsAgo,
|
||||
year,
|
||||
params: {
|
||||
'primary_release_date.gte': `${year}-${m}-${fromD}`,
|
||||
'primary_release_date.lte': `${year}-${m}-${toD}`,
|
||||
'vote_count.gte': '500',
|
||||
'vote_average.gte': '7',
|
||||
sort_by: 'vote_average.desc',
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [today])
|
||||
|
||||
const results = useQueries({
|
||||
queries: queries.map(q => ({
|
||||
queryKey: ['otd', q.year, q.params],
|
||||
queryFn: () => discoverMovies(q.params),
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
})),
|
||||
})
|
||||
|
||||
const picks = useMemo(() => {
|
||||
return queries
|
||||
.map((q, i) => {
|
||||
const res = results[i]?.data
|
||||
const top = (res?.results || [])[0] as TmdbMovie | undefined
|
||||
if (!top || !top.poster_path) return null
|
||||
return { yearsAgo: q.yearsAgo, year: q.year, movie: top }
|
||||
})
|
||||
.filter((x): x is { yearsAgo: number; year: number; movie: TmdbMovie } => !!x)
|
||||
}, [queries, results])
|
||||
|
||||
if (picks.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<div className="px-7 mb-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||
<span className="text-[10.5px] font-semibold text-text-3 uppercase tracking-[0.18em]">
|
||||
This week in cinema
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-text-3 mt-0.5">
|
||||
What dropped around {formatToday(today)} - across the decades.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="-mx-1 overflow-x-auto hide-scrollbar">
|
||||
<ol className="flex items-stretch gap-2.5 px-7 min-w-max">
|
||||
{picks.map((pick, i) => (
|
||||
<motion.li
|
||||
key={pick.year}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.32, delay: Math.min(i * 0.04, 0.32), ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-[280px] shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate(`/item/tmdb-movie-${pick.movie.id}`)}
|
||||
className="group w-full text-left rounded-xl overflow-hidden ring-1 ring-border hover:ring-border-strong transition-all duration-200 bg-elevated/30 flex"
|
||||
>
|
||||
<div className="w-[88px] shrink-0 aspect-[2/3] overflow-hidden bg-void">
|
||||
{pick.movie.poster_path && (
|
||||
<img
|
||||
src={`${TMDB_IMG}/w300${pick.movie.poster_path}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover:scale-[1.04] transition-transform duration-400"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 p-3 flex flex-col">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<CalendarEvent size={11} className="text-accent shrink-0" />
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] font-semibold text-accent tabular-nums">
|
||||
{pick.yearsAgo} years ago
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-display text-[13.5px] font-bold text-text-1 tracking-tight leading-tight line-clamp-2">
|
||||
{pick.movie.title}
|
||||
</p>
|
||||
<p className="text-[10.5px] text-text-3 mt-auto tabular-nums">
|
||||
Released {formatReleaseDate(pick.movie.release_date)}
|
||||
</p>
|
||||
{typeof pick.movie.vote_average === 'number' && (
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-[10.5px] text-accent tabular-nums">
|
||||
<Star size={9} fill="currentColor" stroke={0} />
|
||||
{pick.movie.vote_average.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</motion.li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatToday(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatReleaseDate(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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 as any).adult)
|
||||
return filtered.find(m => (m as any).backdrop_path && (m as any).overview) || null
|
||||
}, [trending.data, lib.data, hideAdult])
|
||||
|
||||
if (!pick) return null
|
||||
|
||||
const p = pick as any
|
||||
const title: string = p.title || p.name || 'Untitled'
|
||||
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}`)
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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