home page rows
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { Filter } from '../../../lib/icons'
|
||||
import { useHasTmdbKey } from '../../../hooks/use-external'
|
||||
import { CANON_LISTS } from '../../../lib/canon-lists'
|
||||
import { STUDIOS, NETWORKS } from '../../../lib/studios-and-networks'
|
||||
import BrandRow from '../../../components/ui/BrandRow'
|
||||
import CanonListRow from '../../../components/ui/CanonListRow'
|
||||
import LetterboxdListRow from '../../../components/ui/LetterboxdListRow'
|
||||
import LetterboxdAddModal from '../../../components/ui/LetterboxdAddModal'
|
||||
import { useLetterboxdLists } from '../../../stores/letterboxd-lists-store'
|
||||
|
||||
/**
|
||||
* "From the studios" - one row per major film studio.
|
||||
*/
|
||||
export function StudioRows() {
|
||||
const hasTmdb = useHasTmdbKey()
|
||||
if (!hasTmdb) return null
|
||||
return (
|
||||
<section className="mb-4 mt-4">
|
||||
<BrandSectionHeader eyebrow="Studios" title="From the studios" subtitle="Films grouped by the company that made them" />
|
||||
{STUDIOS.map(s => (
|
||||
<BrandRow key={s.id} brandId={s.id} label={s.label} subtitle={s.blurb} kind="movie" />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "On the networks" - one row per major TV network.
|
||||
*/
|
||||
export function NetworkRows() {
|
||||
const hasTmdb = useHasTmdbKey()
|
||||
if (!hasTmdb) return null
|
||||
return (
|
||||
<section className="mb-4 mt-4">
|
||||
<BrandSectionHeader eyebrow="Networks" title="On the networks" subtitle="Shows grouped by where they air" />
|
||||
{NETWORKS.map(n => (
|
||||
<BrandRow key={n.id} brandId={n.id} label={n.label} subtitle={n.blurb} kind="tv" />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandSectionHeader({ eyebrow, title, subtitle }: { eyebrow: string; title: string; subtitle: string }) {
|
||||
return (
|
||||
<div className="px-7 mb-5 pt-2">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover canon - renders bundled canon lists (AFI, Sight & Sound, IMDb top).
|
||||
*/
|
||||
export function DiscoverCanonSection() {
|
||||
const hasTmdb = useHasTmdbKey()
|
||||
if (!hasTmdb) return null
|
||||
return (
|
||||
<section className="mb-4 mt-4">
|
||||
<BrandSectionHeader eyebrow="Canon" title="Discover canon" subtitle="Bundled lists from AFI, Sight & Sound, and IMDb" />
|
||||
{CANON_LISTS.map(list => (
|
||||
<CanonListRow key={list.id} list={list} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Letterboxd lists. Each saved list URL renders as its own row lazy-mounted
|
||||
* on scroll. The trailing affordance opens a modal to paste a new URL.
|
||||
*/
|
||||
export function LetterboxdListsSection() {
|
||||
const hasTmdb = useHasTmdbKey()
|
||||
const lists = useLetterboxdLists(s => s.lists)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
if (!hasTmdb) return null
|
||||
return (
|
||||
<>
|
||||
{lists.map(saved => (
|
||||
<LetterboxdListRow key={saved.url} saved={saved} />
|
||||
))}
|
||||
<div className="px-7 mb-10">
|
||||
<button
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-full bg-elevated/50 ring-1 ring-border hover:ring-accent/40 hover:text-accent text-[12.5px] text-text-2 tracking-tight transition focus-ring"
|
||||
>
|
||||
<Filter size={13} />
|
||||
{lists.length === 0 ? 'Add a Letterboxd list' : 'Add another Letterboxd list'}
|
||||
</button>
|
||||
</div>
|
||||
<LetterboxdAddModal open={addOpen} onClose={() => setAddOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState } from 'react'
|
||||
import { Filter } from '../../../lib/icons'
|
||||
import { useLibraryItems } from '../../../hooks/use-jellyfin'
|
||||
import ContentRow from '../../../components/ui/ContentRow'
|
||||
import SmartShelfRow from '../../../components/ui/SmartShelfRow'
|
||||
import SmartShelfWizard from '../../../components/ui/SmartShelfWizard'
|
||||
import LazyMount from '../../../components/ui/LazyMount'
|
||||
import { useSmartShelves } from '../../../stores/smart-shelves-store'
|
||||
import { usePreferencesStore } from '../../../stores/preferences-store'
|
||||
import {
|
||||
BecauseYouWatchedRow,
|
||||
HiddenGemsRow,
|
||||
PersonSpotlights,
|
||||
TimeOfDayRow,
|
||||
UntouchedRow,
|
||||
WatchlistRow,
|
||||
} from './library'
|
||||
import {
|
||||
AwardWinnersMissingRow,
|
||||
ComingSoonRow,
|
||||
CriticallyAcclaimedMissingRow,
|
||||
CultClassicsRow,
|
||||
DocumentaryPicksRow,
|
||||
ForeignCinemaRow,
|
||||
GenreDeepDiveRow,
|
||||
TrendingTodayRow,
|
||||
YearEndBestOfRow,
|
||||
} from './discovery'
|
||||
import {
|
||||
DiscoverCanonSection,
|
||||
LetterboxdListsSection,
|
||||
NetworkRows,
|
||||
StudioRows,
|
||||
} from './brands'
|
||||
|
||||
/**
|
||||
* Renders all the configurable home-page sections in order, gating each on
|
||||
* its individual pref. Keeps the JSX tree at the top of HomePage tidy while
|
||||
* letting users hide whatever they don't want via Settings.
|
||||
*/
|
||||
export function HomeSections() {
|
||||
const prefs = usePreferencesStore()
|
||||
// Each row gets wrapped in LazyMount so we don't fire 25+ data queries +
|
||||
// 400+ poster cards on first paint. The watchlist row stays eager - it's
|
||||
// typically the first thing below the fold and users want it instant.
|
||||
return (
|
||||
<>
|
||||
{prefs.home.show.watchlist && <WatchlistRow />}
|
||||
{prefs.home.show.becauseYouWatched && <LazyMount><BecauseYouWatchedRow /></LazyMount>}
|
||||
{prefs.home.show.personSpotlights && <LazyMount><PersonSpotlights /></LazyMount>}
|
||||
{prefs.home.show.trendingToday && <LazyMount><TrendingTodayRow /></LazyMount>}
|
||||
{prefs.home.show.criticallyAcclaimed && <LazyMount><CriticallyAcclaimedMissingRow /></LazyMount>}
|
||||
{prefs.home.show.genreDeepDive && <LazyMount><GenreDeepDiveRow /></LazyMount>}
|
||||
{prefs.home.show.cultClassics && <LazyMount><CultClassicsRow /></LazyMount>}
|
||||
{prefs.home.show.yearEndBestOf && <LazyMount><YearEndBestOfRow /></LazyMount>}
|
||||
{prefs.home.show.foreignCinema && <LazyMount><ForeignCinemaRow /></LazyMount>}
|
||||
{prefs.home.show.documentaryPicks && <LazyMount><DocumentaryPicksRow /></LazyMount>}
|
||||
{prefs.home.show.awardWinnersMissing && <LazyMount><AwardWinnersMissingRow /></LazyMount>}
|
||||
{prefs.home.show.studios && <LazyMount><StudioRows /></LazyMount>}
|
||||
{prefs.home.show.networks && <LazyMount><NetworkRows /></LazyMount>}
|
||||
{prefs.home.show.discoverCanon && <LazyMount><DiscoverCanonSection /></LazyMount>}
|
||||
{prefs.home.show.letterboxdLists && <LazyMount><LetterboxdListsSection /></LazyMount>}
|
||||
{prefs.home.show.comingSoon && <LazyMount><ComingSoonRow /></LazyMount>}
|
||||
{prefs.home.show.moodPicker && <LazyMount><MoodSection /></LazyMount>}
|
||||
{prefs.home.show.smartShelves && <LazyMount><SmartShelvesSection /></LazyMount>}
|
||||
{prefs.home.show.timeOfDay && <LazyMount><TimeOfDayRow /></LazyMount>}
|
||||
{prefs.home.show.untouched && <LazyMount><UntouchedRow /></LazyMount>}
|
||||
{prefs.home.show.hiddenGems && <LazyMount><HiddenGemsRow /></LazyMount>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood selector. The picker drives a single dynamic row beneath it. Selection
|
||||
* is persisted to localStorage so the user lands on their last mood.
|
||||
*/
|
||||
const MOODS: Array<{ key: string; label: string; genres: string[] }> = [
|
||||
{ key: 'cozy', label: 'Cozy', genres: ['Family', 'Animation', 'Comedy'] },
|
||||
{ key: 'mind-bending', label: 'Mind-bending', genres: ['Mystery', 'Science Fiction', 'Thriller'] },
|
||||
{ key: 'light', label: 'Light', genres: ['Comedy', 'Family', 'Animation'] },
|
||||
{ key: 'heavy', label: 'Heavy', genres: ['Drama', 'War', 'History'] },
|
||||
{ key: 'funny', label: 'Funny', genres: ['Comedy'] },
|
||||
{ key: 'tense', label: 'Tense', genres: ['Thriller', 'Horror', 'Crime'] },
|
||||
]
|
||||
|
||||
function MoodSection() {
|
||||
const [active, setActive] = useState<string | null>(() => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem('home_mood')
|
||||
})
|
||||
const mood = MOODS.find(m => m.key === active) || null
|
||||
const { data } = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
genres: mood?.genres,
|
||||
sortBy: ['Random'],
|
||||
sortOrder: ['Descending'],
|
||||
limit: 16,
|
||||
enabled: !!mood,
|
||||
})
|
||||
function pick(key: string) {
|
||||
const next = active === key ? null : key
|
||||
setActive(next)
|
||||
try {
|
||||
if (next) localStorage.setItem('home_mood', next)
|
||||
else localStorage.removeItem('home_mood')
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
return (
|
||||
<section className="mb-10 px-7">
|
||||
<div className="mb-3.5">
|
||||
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight">What's your mood?</h2>
|
||||
<p className="text-[12px] text-text-3 mt-0.5">Tap one to refresh the row below</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{MOODS.map(m => {
|
||||
const on = active === m.key
|
||||
return (
|
||||
<button
|
||||
key={m.key}
|
||||
onClick={() => pick(m.key)}
|
||||
className={`h-8 px-3.5 rounded-full text-[12px] font-medium tracking-tight transition border ${
|
||||
on
|
||||
? 'bg-accent text-void border-accent'
|
||||
: 'bg-elevated/40 text-text-2 border-border hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{mood && data?.Items && data.Items.length > 0 && (
|
||||
<div className="-mx-7">
|
||||
<ContentRow
|
||||
title={`${mood.label} picks`}
|
||||
subtitle={mood.genres.join(' · ')}
|
||||
items={data.Items}
|
||||
layoutKey={`mood_${mood.key}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart shelves. Renders one ContentRow per saved rule plus a trailing
|
||||
* "New smart shelf" affordance.
|
||||
*/
|
||||
function SmartShelvesSection() {
|
||||
const shelves = useSmartShelves(s => s.shelves)
|
||||
const [wizardOpen, setWizardOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
{shelves.map(s => (
|
||||
<SmartShelfRow key={s.id} rule={s} />
|
||||
))}
|
||||
<div className="px-7 mb-10">
|
||||
<button
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-full bg-elevated/50 ring-1 ring-border hover:ring-accent/40 hover:text-accent text-[12.5px] text-text-2 tracking-tight transition focus-ring"
|
||||
>
|
||||
<Filter size={13} />
|
||||
{shelves.length === 0 ? 'Create a smart shelf' : 'New smart shelf'}
|
||||
</button>
|
||||
</div>
|
||||
<SmartShelfWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
useTmdbTrending,
|
||||
useTmdbUpcoming,
|
||||
useTmdbTopRatedMovies,
|
||||
useTmdbTopRatedTv,
|
||||
useTmdbDiscoverMovies,
|
||||
useTmdbDiscoverTv,
|
||||
} from '../../../hooks/use-tmdb'
|
||||
import { useLibraryByTmdbId, useLibraryItems } from '../../../hooks/use-jellyfin'
|
||||
import { useWikidataAwardWinners } from '../../../hooks/use-external'
|
||||
import { useCanonListResolved } from '../../../hooks/use-canon-list'
|
||||
import { topGenre } from '../../../lib/top-genre'
|
||||
import { tmdbMovieGenreId, tmdbTvGenreId } from '../../../lib/tmdb-genres'
|
||||
import { mapTmdbToJf } from '../../../lib/tmdb-mapping'
|
||||
import { regionForUser } from '../../../lib/format'
|
||||
import ContentRow from '../../../components/ui/ContentRow'
|
||||
import { usePreferencesStore } from '../../../stores/preferences-store'
|
||||
|
||||
/**
|
||||
* Trending today row. Companion to weekly trending - daily catches the buzz,
|
||||
* weekly smooths it out.
|
||||
*/
|
||||
export function TrendingTodayRow() {
|
||||
const trending = useTmdbTrending('all', 'day')
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const items = useMemo(() => {
|
||||
const raw = trending.data?.results || []
|
||||
const filtered = raw.filter(r => !hideAdult || !r.adult)
|
||||
return mapTmdbToJf(filtered, libraryByTmdbId.data)
|
||||
}, [trending.data, libraryByTmdbId.data, hideAdult])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Trending today"
|
||||
subtitle="What's getting attention right now on TMDB"
|
||||
items={items}
|
||||
layoutKey="trending_today"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Critically acclaimed missing. TMDB top-rated movies and series filtered
|
||||
* against the library - only items NOT in the library show up.
|
||||
*/
|
||||
export function CriticallyAcclaimedMissingRow() {
|
||||
const movies = useTmdbTopRatedMovies()
|
||||
const tv = useTmdbTopRatedTv()
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const lib = libraryByTmdbId.data
|
||||
if (!lib) return []
|
||||
const movieList = (movies.data?.results || [])
|
||||
.map(m => ({ ...m, media_type: 'movie' }))
|
||||
.filter(m => !lib.has(String(m.id)))
|
||||
const tvList = (tv.data?.results || [])
|
||||
.map(m => ({ ...m, media_type: 'tv' }))
|
||||
.filter(m => !lib.has(String(m.id)))
|
||||
// Interleave so the row mixes both types instead of 20 movies then 20 series.
|
||||
const out: any[] = []
|
||||
const max = Math.max(movieList.length, tvList.length)
|
||||
for (let i = 0; i < max && out.length < 20; i++) {
|
||||
if (movieList[i]) out.push(movieList[i])
|
||||
if (tvList[i]) out.push(tvList[i])
|
||||
}
|
||||
return mapTmdbToJf(out, lib)
|
||||
}, [movies.data, tv.data, libraryByTmdbId.data])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Critically acclaimed - missing"
|
||||
subtitle="Top-rated on TMDB and not yet in your library"
|
||||
items={items}
|
||||
layoutKey="critically_acclaimed_missing"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Genre deep-dive. Tallies the user's recently watched library genres, picks
|
||||
* the leader, surfaces TMDB-discover canon in that genre filtered to items
|
||||
* not in the library.
|
||||
*/
|
||||
export function GenreDeepDiveRow() {
|
||||
const recentlyPlayed = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
sortBy: ['DatePlayed'],
|
||||
sortOrder: ['Descending'],
|
||||
filters: ['IsPlayed'],
|
||||
limit: 30,
|
||||
})
|
||||
const top = topGenre(recentlyPlayed.data?.Items)
|
||||
const movieGenreId = top.primary ? tmdbMovieGenreId(top.primary) : null
|
||||
const tvGenreId = top.primary ? tmdbTvGenreId(top.primary) : null
|
||||
|
||||
const movieDiscover = useTmdbDiscoverMovies(
|
||||
movieGenreId
|
||||
? {
|
||||
with_genres: String(movieGenreId),
|
||||
'vote_count.gte': '1500',
|
||||
sort_by: 'vote_average.desc',
|
||||
}
|
||||
: ({} as Record<string, string>),
|
||||
)
|
||||
const tvDiscover = useTmdbDiscoverTv(
|
||||
tvGenreId
|
||||
? {
|
||||
with_genres: String(tvGenreId),
|
||||
'vote_count.gte': '500',
|
||||
sort_by: 'vote_average.desc',
|
||||
}
|
||||
: ({} as Record<string, string>),
|
||||
)
|
||||
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const lib = libraryByTmdbId.data
|
||||
if (!lib || !top.primary) return []
|
||||
const movies = (movieDiscover.data?.results || [])
|
||||
.filter(m => !lib.has(String(m.id)))
|
||||
.map(m => ({ ...m, media_type: 'movie' }))
|
||||
const tv = (tvDiscover.data?.results || [])
|
||||
.filter(m => !lib.has(String(m.id)))
|
||||
.map(m => ({ ...m, media_type: 'tv' }))
|
||||
const out: any[] = []
|
||||
const max = Math.max(movies.length, tv.length)
|
||||
for (let i = 0; i < max && out.length < 18; i++) {
|
||||
if (movies[i]) out.push(movies[i])
|
||||
if (tv[i]) out.push(tv[i])
|
||||
}
|
||||
return mapTmdbToJf(out, lib)
|
||||
}, [movieDiscover.data, tvDiscover.data, libraryByTmdbId.data, top.primary])
|
||||
|
||||
if (!top.primary || items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={`Your top genre: ${top.primary}`}
|
||||
subtitle="Canon you haven't picked up yet"
|
||||
items={items}
|
||||
layoutKey={`genre_deep_${top.primary}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cult classics. Highly rated films with enough votes to be canon but capped
|
||||
* popularity so we surface the cult tier rather than blockbusters.
|
||||
*/
|
||||
export function CultClassicsRow() {
|
||||
const discover = useTmdbDiscoverMovies({
|
||||
'vote_count.gte': '10000',
|
||||
'vote_average.gte': '8',
|
||||
'popularity.lte': '20',
|
||||
sort_by: 'vote_average.desc',
|
||||
})
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const raw = (discover.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||
}, [discover.data, libraryByTmdbId.data])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Cult classics"
|
||||
subtitle="Highly rated films flying under the radar"
|
||||
items={items}
|
||||
layoutKey="cult_classics"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Year-end best-ofs. Top-rated movies released in the current year.
|
||||
*/
|
||||
export function YearEndBestOfRow() {
|
||||
const year = new Date().getFullYear()
|
||||
const discover = useTmdbDiscoverMovies({
|
||||
primary_release_year: String(year),
|
||||
'vote_count.gte': '200',
|
||||
sort_by: 'vote_average.desc',
|
||||
})
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const raw = (discover.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||
}, [discover.data, libraryByTmdbId.data])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={`Top movies of ${year}`}
|
||||
subtitle="The best-rated theatrical and streaming releases this year"
|
||||
items={items}
|
||||
layoutKey={`year_end_${year}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreign cinema. Drops English originals client-side from the broad
|
||||
* rated-discover pool.
|
||||
*/
|
||||
export function ForeignCinemaRow() {
|
||||
const discover = useTmdbDiscoverMovies({
|
||||
'vote_count.gte': '500',
|
||||
sort_by: 'vote_average.desc',
|
||||
})
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const raw = (discover.data?.results || [])
|
||||
.filter((m: any) => m.original_language && m.original_language !== 'en')
|
||||
.slice(0, 20)
|
||||
.map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||
}, [discover.data, libraryByTmdbId.data])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Foreign cinema"
|
||||
subtitle="Top-rated non-English films"
|
||||
items={items}
|
||||
layoutKey="foreign_cinema"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Documentary picks. Genre 99 = Documentary in TMDB.
|
||||
*/
|
||||
export function DocumentaryPicksRow() {
|
||||
const discover = useTmdbDiscoverMovies({
|
||||
with_genres: '99',
|
||||
'vote_count.gte': '300',
|
||||
sort_by: 'vote_average.desc',
|
||||
})
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const raw = (discover.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||
}, [discover.data, libraryByTmdbId.data])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Documentary picks"
|
||||
subtitle="The best-rated docs on TMDB"
|
||||
items={items}
|
||||
layoutKey="documentary_picks"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Award winners you're missing. Wikidata Q-id 102427 is "Academy Award for
|
||||
* Best Picture"; the SPARQL gives us all winners with TMDB cross-refs.
|
||||
*/
|
||||
const ACADEMY_AWARD_BEST_PICTURE = 'Q102427'
|
||||
|
||||
export function AwardWinnersMissingRow() {
|
||||
// Lazy-mount on scroll - sits deep in the home page, don't fire 18 TMDB
|
||||
// lookups on first paint.
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [near, setNear] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const el = containerRef.current
|
||||
const obs = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
setNear(true)
|
||||
obs.disconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' },
|
||||
)
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
return <div ref={containerRef}>{near ? <AwardWinnersMissingRowMounted /> : null}</div>
|
||||
}
|
||||
|
||||
function AwardWinnersMissingRowMounted() {
|
||||
const winners = useWikidataAwardWinners(ACADEMY_AWARD_BEST_PICTURE)
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
// Wikidata gives us TMDB ids + labels but no posters. Resolve them so the
|
||||
// row renders real artwork instead of letter placeholders.
|
||||
const missingIds = useMemo(() => {
|
||||
const list = winners.data || []
|
||||
const lib = libraryByTmdbId.data
|
||||
if (!lib) return [] as number[]
|
||||
return list
|
||||
.filter(w => w.tmdbId && w.type === 'movie' && !lib.has(w.tmdbId))
|
||||
.slice(0, 18)
|
||||
.map(w => Number(w.tmdbId))
|
||||
.filter(n => Number.isFinite(n))
|
||||
}, [winners.data, libraryByTmdbId.data])
|
||||
const { items: tmdbItems } = useCanonListResolved(missingIds)
|
||||
const items = useMemo(
|
||||
() => mapTmdbToJf(tmdbItems, libraryByTmdbId.data),
|
||||
[tmdbItems, libraryByTmdbId.data],
|
||||
)
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Best Picture winners you're missing"
|
||||
subtitle="Academy Awards canon, filtered against your library"
|
||||
items={items}
|
||||
layoutKey="award_winners_missing"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coming soon row. TMDB upcoming movies, region-filtered.
|
||||
*/
|
||||
export function ComingSoonRow() {
|
||||
const prefs = usePreferencesStore()
|
||||
const region = prefs.region || regionForUser()
|
||||
const upcoming = useTmdbUpcoming(region)
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
const items = useMemo(() => {
|
||||
const raw = (upcoming.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||
}, [upcoming.data, libraryByTmdbId.data])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Coming soon"
|
||||
subtitle={`New theatrical and streaming releases${region ? ` in ${region}` : ''}`}
|
||||
items={items}
|
||||
layoutKey="coming_soon"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLibraryItems, useLibraryByTmdbId } from '../../../hooks/use-jellyfin'
|
||||
import { useTmdbMovie, useTmdbTvShow } from '../../../hooks/use-tmdb'
|
||||
import { useWatchlist } from '../../../hooks/use-watchlist'
|
||||
import { usePersonSpotlights } from '../../../hooks/use-person-spotlights'
|
||||
import { mapTmdbToJf } from '../../../lib/tmdb-mapping'
|
||||
import ContentRow from '../../../components/ui/ContentRow'
|
||||
import PersonSpotlightRow from '../../../components/ui/PersonSpotlightRow'
|
||||
import { pickTimeOfDaySlot } from '../home-utils'
|
||||
|
||||
/**
|
||||
* Library decade rows. Buckets the user's library into the chosen decade.
|
||||
* Hidden when a decade has fewer than 6 items so it doesn't feel sparse.
|
||||
*/
|
||||
export function DecadeRow({ decade }: { decade: number }) {
|
||||
const years = Array.from({ length: 10 }, (_, i) => decade + i)
|
||||
const { data } = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
years,
|
||||
sortBy: ['Random'],
|
||||
sortOrder: ['Descending'],
|
||||
limit: 18,
|
||||
})
|
||||
const items = data?.Items || []
|
||||
if (items.length < 6) return null
|
||||
const decadeLabel = `${decade}s`
|
||||
const subtitle =
|
||||
decade <= 1980
|
||||
? 'Vintage finds from your library'
|
||||
: decade === 1990
|
||||
? 'A nineties revival'
|
||||
: decade === 2000
|
||||
? 'Y2K and after'
|
||||
: decade === 2010
|
||||
? 'The streaming-era classics'
|
||||
: 'Recent canon'
|
||||
return <ContentRow title={decadeLabel} subtitle={subtitle} items={items} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden gems. Library items the user has never opened, sorted by community
|
||||
* rating. The 7.5 threshold keeps the bar high enough that the row feels
|
||||
* curated.
|
||||
*/
|
||||
export function HiddenGemsRow() {
|
||||
const { data } = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
sortBy: ['CommunityRating'],
|
||||
sortOrder: ['Descending'],
|
||||
minCommunityRating: 7.5,
|
||||
filters: ['IsUnplayed'],
|
||||
limit: 16,
|
||||
})
|
||||
const items = data?.Items || []
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Hidden gems"
|
||||
subtitle="Highly rated and still unwatched in your library"
|
||||
items={items}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recently added items the user hasn't played at all. Distinct from
|
||||
* "Recently added" (any recent) and from "Continue watching" (already
|
||||
* started).
|
||||
*/
|
||||
export function UntouchedRow() {
|
||||
const { data } = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
sortBy: ['DateCreated'],
|
||||
sortOrder: ['Descending'],
|
||||
filters: ['IsUnplayed'],
|
||||
limit: 14,
|
||||
})
|
||||
const items = data?.Items || []
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Added but not yet started"
|
||||
subtitle="New arrivals you haven't opened"
|
||||
items={items}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-of-day picker. The slot rotates between four moods based on local
|
||||
* time: morning calm, afternoon adventure, evening drama, late-night dark.
|
||||
*/
|
||||
export function TimeOfDayRow() {
|
||||
const slot = pickTimeOfDaySlot()
|
||||
const { data } = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
genres: slot.genres,
|
||||
sortBy: ['Random'],
|
||||
sortOrder: ['Descending'],
|
||||
limit: 14,
|
||||
})
|
||||
const items = data?.Items || []
|
||||
if (items.length === 0) return null
|
||||
return <ContentRow title={slot.title} subtitle={slot.subtitle} items={items} />
|
||||
}
|
||||
|
||||
export function GenreRow({ genre, subtitle }: { genre: string; subtitle: string }) {
|
||||
const { data } = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
genres: [genre],
|
||||
sortBy: ['Random'],
|
||||
sortOrder: ['Descending'],
|
||||
limit: 16,
|
||||
})
|
||||
const items = data?.Items || []
|
||||
if (items.length === 0) return null
|
||||
return <ContentRow title={genre} subtitle={subtitle} items={items} />
|
||||
}
|
||||
|
||||
/**
|
||||
* "Because you watched X". Picks the user's most recent finished item with
|
||||
* a TMDB id, fetches TMDB recommendations, filters out library items.
|
||||
*/
|
||||
export function BecauseYouWatchedRow() {
|
||||
const recentlyPlayed = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
sortBy: ['DatePlayed'],
|
||||
sortOrder: ['Descending'],
|
||||
filters: ['IsPlayed'],
|
||||
limit: 10,
|
||||
})
|
||||
const seed = useMemo(() => {
|
||||
const list = recentlyPlayed.data?.Items || []
|
||||
return list.find(it => !!(it.ProviderIds?.Tmdb)) || null
|
||||
}, [recentlyPlayed.data])
|
||||
|
||||
const tmdbId = seed?.ProviderIds?.Tmdb ? Number(seed.ProviderIds.Tmdb) : null
|
||||
const isSeries = seed?.Type === 'Series'
|
||||
const movieFull = useTmdbMovie(!isSeries ? tmdbId : null)
|
||||
const tvFull = useTmdbTvShow(isSeries ? tmdbId : null)
|
||||
const libraryByTmdbId = useLibraryByTmdbId()
|
||||
|
||||
const items = useMemo(() => {
|
||||
const recs = isSeries
|
||||
? tvFull.data?.recommendations?.results
|
||||
: movieFull.data?.recommendations?.results
|
||||
if (!recs) return []
|
||||
const lib = libraryByTmdbId.data
|
||||
const filtered = recs.filter(r => !lib?.has(String(r.id)))
|
||||
return mapTmdbToJf(filtered, lib)
|
||||
}, [movieFull.data, tvFull.data, isSeries, libraryByTmdbId.data])
|
||||
|
||||
if (!seed || items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={`Because you watched "${seed.Name}"`}
|
||||
subtitle="Picks from TMDB you don't have yet"
|
||||
items={items}
|
||||
layoutKey="because_you_watched"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Director and actor spotlight rows. Aggregates the user's recently watched
|
||||
* library items, surfaces persons watched at least twice. Renders a row each
|
||||
* for the top director and top actor.
|
||||
*/
|
||||
export function PersonSpotlights() {
|
||||
const recentlyPlayed = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
sortBy: ['DatePlayed'],
|
||||
sortOrder: ['Descending'],
|
||||
filters: ['IsPlayed'],
|
||||
limit: 8,
|
||||
})
|
||||
const spotlights = usePersonSpotlights(recentlyPlayed.data?.Items)
|
||||
return (
|
||||
<>
|
||||
{spotlights.director && (
|
||||
<PersonSpotlightRow
|
||||
personId={spotlights.director.id}
|
||||
name={spotlights.director.name}
|
||||
profilePath={spotlights.director.profile_path}
|
||||
role="director"
|
||||
watchedCount={spotlights.director.count}
|
||||
/>
|
||||
)}
|
||||
{spotlights.actor && (
|
||||
<PersonSpotlightRow
|
||||
personId={spotlights.actor.id}
|
||||
name={spotlights.actor.name}
|
||||
profilePath={spotlights.actor.profile_path}
|
||||
role="actor"
|
||||
watchedCount={spotlights.actor.count}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Watchlist row. Reads from the user's "Watchlist" Jellyfin playlist.
|
||||
*/
|
||||
export function WatchlistRow() {
|
||||
const { items } = useWatchlist()
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title="Your watchlist"
|
||||
subtitle="Saved for later"
|
||||
items={items as any}
|
||||
seeAllHref="/playlists"
|
||||
layoutKey="watchlist"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user