discover components

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