discover helpers

This commit is contained in:
2026-03-31 23:38:23 +03:00
parent 8789bd2f4a
commit 989cce5ad5
4 changed files with 527 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
import { Compass } from '../../lib/icons'
export function NoKey() {
return (
<div className="px-7 py-20 max-w-xl mx-auto text-center">
<div className="relative w-14 h-14 mx-auto mb-5">
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
<Compass size={20} className="text-text-3" />
</div>
</div>
<h1 className="font-display text-[22px] font-bold text-text-1 tracking-tight mb-2">
Discover needs a TMDB key
</h1>
<p className="text-[13px] text-text-3 leading-relaxed mb-5">
This page surfaces films and shows you don't already own. It pulls trending, top-rated, upcoming, by-genre, by-language, by-studio, and by-network rows from TMDB - all of which require an API key to load.
</p>
<a
href="/settings#tmdb"
className="inline-flex items-center gap-2 h-9 px-4 rounded-full bg-accent text-void font-semibold text-[12.5px] hover:bg-accent-hover transition focus-ring"
>
Open TMDB settings
</a>
</div>
)
}
export function Hero() {
return (
<div className="px-7 pt-7 pb-5">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3 rounded-full bg-accent" />
<span className="text-[10px] font-semibold text-text-3 uppercase tracking-[0.2em]">
Discover
</span>
</div>
<h1 className="font-display text-[26px] md:text-[30px] font-bold tracking-tight text-text-1 leading-[1.1] mb-1">
Find your next favorite
</h1>
<p className="text-[12.5px] text-text-3 max-w-xl">
Films and shows you don't have yet, sourced from TMDB.
</p>
</div>
)
}
export function SectionHeader({ eyebrow, title, subtitle }: { eyebrow: string; title: string; subtitle: string }) {
return (
<div className="px-7 mb-5 pt-6">
<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>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import PosterCard from '../../components/ui/PosterCard'
import { usePosterGridClasses } from '../../lib/density'
import { filtersToTmdbParams, type DiscoverFilterState } from '../../components/discover/DiscoverFilters'
import type { BaseItemDto } from '../../api/types'
/**
* Shown when any filter is active or sort has been changed from the default.
* Renders a single grid of TMDB results constrained by the filter selections,
* with optional library-hide.
*/
export function FilteredGrid({ filters }: { filters: DiscoverFilterState }) {
const navigate = useNavigate()
const lib = useLibraryByTmdbId()
const grid = usePosterGridClasses()
const params = useMemo(() => filtersToTmdbParams(filters), [filters])
const moviesQ = useTmdbDiscoverMovies(filters.kind === 'movie' ? params : ({} as Record<string, string>))
const tvQ = useTmdbDiscoverTv(filters.kind === 'tv' ? params : ({} as Record<string, string>))
const data = filters.kind === 'movie' ? moviesQ.data : tvQ.data
const isLoading = filters.kind === 'movie' ? moviesQ.isLoading : tvQ.isLoading
const items = useMemo(() => {
const raw = (data?.results || []).map(m => ({
...m,
media_type: filters.kind === 'movie' ? 'movie' : 'tv',
}))
let list = raw
if (filters.hideOwned && lib.data) {
list = list.filter(m => !lib.data!.has(String(m.id)))
}
return mapTmdbToJf(list, lib.data) as BaseItemDto[]
}, [data, filters.kind, filters.hideOwned, lib.data])
return (
<div className="px-7 pt-6">
<div className="mb-4 flex items-center justify-between gap-3">
<p className="text-[12px] text-text-3 tracking-tight">
{isLoading ? (
'Searching...'
) : (
<>
<span className="tabular-nums text-text-1 font-semibold">{items.length}</span>
<span className="text-text-3"> matching {filters.kind === 'movie' ? 'movies' : 'shows'}</span>
{filters.hideOwned && (
<span className="text-text-4"> (excluding your library)</span>
)}
</>
)}
</p>
</div>
{isLoading ? (
<div className={grid}>
{Array.from({ length: 12 }).map((_, i) => (
<div key={i}>
<div className="skeleton aspect-[2/3] rounded-lg" />
<div className="mt-2.5 space-y-1.5">
<div className="skeleton h-3 w-3/4 rounded" />
<div className="skeleton h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</div>
) : items.length === 0 ? (
<div className="py-20 text-center max-w-md mx-auto">
<p className="font-display text-[18px] text-text-1 font-semibold tracking-tight mb-2">
No matches
</p>
<p className="text-[13px] text-text-3 leading-relaxed">
Loosen the filters to see more results. Reduce the minimum rating, expand the year range, or clear genre selections.
</p>
</div>
) : (
<div className={grid}>
{items.map((item, i) => (
<PosterCard
key={item.Id || i}
item={item}
priority={i < 6}
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
/>
))}
</div>
)}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Filter raw TMDB results down to ones the user does NOT own. The library
* map keys items by their TMDB id; we drop anything that hits. When no
* library map has loaded yet, returns an empty array so rows hide rather
* than briefly showing items the user already owns.
*/
export function filterToMissing<T extends { id: number; original_language?: string }>(
raw: T[],
lib?: Map<string, unknown> | null,
hideAdult = false,
adultFlag?: (m: T) => boolean,
): T[] {
if (!lib) return []
let list = raw.filter(m => !lib.has(String(m.id)))
if (hideAdult && adultFlag) list = list.filter(m => !adultFlag(m))
return list
}
export const GENRE_ROWS: Array<{ label: string; subtitle: string }> = [
{ label: 'Action', subtitle: 'Bullets, fists, explosions' },
{ label: 'Drama', subtitle: 'Stories that hit deep' },
{ label: 'Comedy', subtitle: 'Light watching' },
{ label: 'Science Fiction', subtitle: 'Beyond the possible' },
{ label: 'Thriller', subtitle: 'Edge-of-your-seat tension' },
{ label: 'Horror', subtitle: 'Lights off' },
{ label: 'Animation', subtitle: 'Drawn to perfection' },
{ label: 'Romance', subtitle: 'Love and longing' },
{ label: 'Fantasy', subtitle: 'Worlds of magic' },
{ label: 'Mystery', subtitle: 'Puzzles to solve' },
]
export const LANGUAGE_ROWS: Array<{ code: string; label: string; subtitle: string }> = [
{ code: 'ja', label: 'Japanese cinema', subtitle: 'From Kurosawa to Kore-eda' },
{ code: 'ko', label: 'Korean cinema', subtitle: 'New wave thrillers and drama' },
{ code: 'fr', label: 'French cinema', subtitle: 'Auteurs and arthouse' },
{ code: 'es', label: 'Spanish-language cinema', subtitle: 'Spain and Latin America' },
{ code: 'de', label: 'German cinema', subtitle: 'Berlin school and beyond' },
{ code: 'it', label: 'Italian cinema', subtitle: 'Neo-realism to giallo' },
{ code: 'zh', label: 'Chinese-language cinema', subtitle: 'Mainland, Hong Kong, Taiwan' },
{ code: 'hi', label: 'Hindi cinema', subtitle: 'Bollywood at its best' },
]
+337
View File
@@ -0,0 +1,337 @@
import { useMemo } from 'react'
import {
useTmdbTrending,
useTmdbDiscoverMovies,
useTmdbDiscoverTv,
useTmdbUpcoming,
useTmdbTopRatedMovies,
useTmdbTopRatedTv,
} from '../../hooks/use-tmdb'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres'
import ContentRow from '../../components/ui/ContentRow'
import { usePreferencesStore } from '../../stores/preferences-store'
import { filterToMissing } from './helpers'
export function TrendingDayRow() {
const trending = useTmdbTrending('all', 'day')
const lib = useLibraryByTmdbId()
const hideAdult = usePreferencesStore(s => s.hideAdult)
const items = useMemo(() => {
const raw = (trending.data?.results || [])
const filtered = filterToMissing(raw, lib.data, hideAdult, m => !!m.adult)
return mapTmdbToJf(filtered, lib.data)
}, [trending.data, lib.data, hideAdult])
if (items.length === 0) return null
return (
<ContentRow
title="Trending today"
subtitle="Buzz on TMDB right now - things you don't have"
items={items}
layoutKey="discover_trending_day"
/>
)
}
export function TrendingWeekRow() {
const trending = useTmdbTrending('all', 'week')
const lib = useLibraryByTmdbId()
const hideAdult = usePreferencesStore(s => s.hideAdult)
const items = useMemo(() => {
const raw = (trending.data?.results || [])
const filtered = filterToMissing(raw, lib.data, hideAdult, m => !!m.adult)
return mapTmdbToJf(filtered, lib.data)
}, [trending.data, lib.data, hideAdult])
if (items.length === 0) return null
return (
<ContentRow
title="Trending this week"
subtitle="The week's biggest titles you're missing"
items={items}
layoutKey="discover_trending_week"
/>
)
}
export function PopularMoviesRow() {
const movies = useTmdbDiscoverMovies({ sort_by: 'popularity.desc' })
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title="Popular movies"
subtitle="Top of TMDB right now - missing from your library"
items={items}
layoutKey="discover_popular_movies"
/>
)
}
export function PopularTvRow() {
const tv = useTmdbDiscoverTv({ sort_by: 'popularity.desc' })
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="Popular shows"
subtitle="Top of TMDB right now - missing from your library"
items={items}
layoutKey="discover_popular_tv"
/>
)
}
export function UpcomingMoviesRow({ region }: { region: string }) {
const upcoming = useTmdbUpcoming(region)
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (upcoming.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [upcoming.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title="Coming soon"
subtitle={`New theatrical and streaming releases${region ? ` in ${region}` : ''}`}
items={items}
layoutKey={`discover_upcoming_${region}`}
/>
)
}
export function TopRatedMoviesRow() {
const movies = useTmdbTopRatedMovies()
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title="Critically acclaimed movies"
subtitle="The highest-rated films on TMDB you don't yet own"
items={items}
layoutKey="discover_top_rated_movies"
/>
)
}
export function TopRatedTvRow() {
const tv = useTmdbTopRatedTv()
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="Critically acclaimed shows"
subtitle="The highest-rated series on TMDB you don't yet own"
items={items}
layoutKey="discover_top_rated_tv"
/>
)
}
export function CultClassicsRow() {
const movies = useTmdbDiscoverMovies({
'vote_count.gte': '10000',
'vote_average.gte': '8',
'popularity.lte': '20',
sort_by: 'vote_average.desc',
})
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title="Cult classics"
subtitle="Highly rated films flying under the radar"
items={items}
layoutKey="discover_cult"
/>
)
}
export function HighlyRatedThisYearRow() {
const year = new Date().getFullYear()
const movies = useTmdbDiscoverMovies({
primary_release_year: String(year),
'vote_count.gte': '200',
sort_by: 'vote_average.desc',
})
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title={`Highly rated this year (${year})`}
subtitle="The best-rated theatrical and streaming releases of the year"
items={items}
layoutKey={`discover_year_${year}`}
/>
)
}
export function ForeignFilmsRow() {
const movies = useTmdbDiscoverMovies({
'vote_count.gte': '500',
sort_by: 'vote_average.desc',
})
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || [])
.filter(m => m.original_language && m.original_language !== 'en')
.slice(0, 20)
.map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title="Foreign cinema"
subtitle="Top-rated non-English films you don't own"
items={items}
layoutKey="discover_foreign"
/>
)
}
export function DocumentariesRow() {
const movies = useTmdbDiscoverMovies({
with_genres: '99',
'vote_count.gte': '300',
sort_by: 'vote_average.desc',
})
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title="Documentaries"
subtitle="The best-rated docs on TMDB you don't have"
items={items}
layoutKey="discover_docs"
/>
)
}
export function GenreRow({ genre }: { genre: { label: string; subtitle: string } }) {
const movieGenreId = tmdbMovieGenreId(genre.label)
const tvGenreId = tmdbTvGenreId(genre.label)
const movies = useTmdbDiscoverMovies(
movieGenreId
? { with_genres: String(movieGenreId), 'vote_count.gte': '500', sort_by: 'vote_average.desc' }
: ({} as Record<string, string>),
)
const tv = useTmdbDiscoverTv(
tvGenreId
? { with_genres: String(tvGenreId), 'vote_count.gte': '200', sort_by: 'vote_average.desc' }
: ({} as Record<string, string>),
)
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const m = (movies.data?.results || []).map(x => ({ ...x, media_type: 'movie' }))
const t = (tv.data?.results || []).map(x => ({ ...x, media_type: 'tv' }))
const out: { id: number; media_type: string }[] = []
const max = Math.max(m.length, t.length)
for (let i = 0; i < max && out.length < 20; i++) {
if (m[i]) out.push(m[i])
if (t[i]) out.push(t[i])
}
return mapTmdbToJf(filterToMissing(out, lib.data), lib.data)
}, [movies.data, tv.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title={genre.label}
subtitle={genre.subtitle}
items={items}
layoutKey={`discover_genre_${genre.label}`}
/>
)
}
export function LanguageRow({ lang }: { lang: { code: string; label: string; subtitle: string } }) {
const movies = useTmdbDiscoverMovies({
with_original_language: lang.code,
'vote_count.gte': '200',
sort_by: 'vote_average.desc',
})
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title={lang.label}
subtitle={lang.subtitle}
items={items}
layoutKey={`discover_lang_${lang.code}`}
/>
)
}
export function StudioRow({ brandId, label, subtitle }: { brandId: number; label: string; subtitle: string }) {
const movies = useTmdbDiscoverMovies({
with_companies: String(brandId),
sort_by: 'popularity.desc',
})
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (movies.data?.results || []).slice(0, 18).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
}, [movies.data, lib.data])
if (items.length === 0) return null
return (
<ContentRow
title={label}
subtitle={subtitle}
items={items}
layoutKey={`discover_studio_${brandId}`}
/>
)
}
export function NetworkRow({ brandId, label, subtitle }: { brandId: number; label: string; subtitle: string }) {
const tv = useTmdbDiscoverTv({
with_networks: String(brandId),
sort_by: 'popularity.desc',
})
const lib = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (tv.data?.results || []).slice(0, 18).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={label}
subtitle={subtitle}
items={items}
layoutKey={`discover_network_${brandId}`}
/>
)
}