main pages

This commit is contained in:
2026-03-30 13:40:42 +03:00
parent 3be784d675
commit 430981cbf7
19 changed files with 6531 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
import { useParams, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Boxes, Calendar } from '../lib/icons'
import { useTmdbCollection } from '../hooks/use-tmdb'
import { getTmdbImageUrl } from '../api/tmdb'
import { usePosterGridClasses } from '../lib/density'
export default function CollectionPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: collection, isLoading } = useTmdbCollection(id)
const gridCls = usePosterGridClasses()
if (isLoading) return <CollectionSkeleton />
if (!collection) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-6">
<p className="text-text-2 text-[15px] mb-1">Collection not found</p>
</div>
)
}
// Sort by release date ascending; items with no/unparseable date sort
// to the end so unannounced sequels don't end up at #1.
const parts = (collection.parts || []).slice().sort((a, b) => {
const ta = a.release_date ? Date.parse(a.release_date) : NaN
const tb = b.release_date ? Date.parse(b.release_date) : NaN
const da = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY
const db = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY
return da - db
})
const totalRuntime = parts.reduce((sum, p) => sum + (p.runtime || 0), 0)
const avgRating =
parts.length > 0
? parts.reduce((sum, p) => sum + (p.vote_average || 0), 0) / parts.length
: 0
return (
<div className="pb-12">
{/* Hero */}
<div className="relative h-[50vh] min-h-[380px] -mt-14 overflow-hidden">
{collection.backdrop_path && (
<motion.div
initial={{ scale: 1.06, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-0 bg-cover bg-top"
style={{ backgroundImage: `url(${getTmdbImageUrl(collection.backdrop_path, 'w1920')})` }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/65 to-void/15" />
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
<div className="relative h-full flex items-end pb-12 px-7">
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
className="max-w-3xl"
>
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-accent/12 border border-accent/30 text-accent text-[10px] font-semibold uppercase tracking-[0.12em] mb-3">
<Boxes size={10} />
Collection
</span>
<h1 className="font-display text-4xl md:text-5xl font-bold text-white tracking-tight leading-[1] mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)]">
{collection.name}
</h1>
<div className="flex items-center gap-3 text-[12px] text-white/70 font-medium mb-3 flex-wrap">
<span>{parts.length} {parts.length === 1 ? 'film' : 'films'}</span>
{totalRuntime > 0 && (
<>
<span className="text-white/30">·</span>
<span className="tabular-nums">
{Math.floor(totalRuntime / 60)}h {totalRuntime % 60}m total
</span>
</>
)}
{avgRating > 0 && (
<>
<span className="text-white/30">·</span>
<span className="tabular-nums">{avgRating.toFixed(1)} avg rating</span>
</>
)}
</div>
{collection.overview && (
<p className="text-[14px] text-white/80 leading-relaxed line-clamp-3 max-w-2xl">
{collection.overview}
</p>
)}
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-b from-transparent to-void pointer-events-none" />
</div>
{/* Films */}
<div className="px-7 pt-2">
<div className="flex items-center gap-2 mb-4">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<h3 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">In order of release</h3>
</div>
<div className={gridCls}>
{parts.map((m, i) => (
<motion.button
key={m.id}
onClick={() => navigate(`/item/tmdb-${m.id}`)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }}
whileHover={{ y: -3 }}
className="group text-left focus-ring rounded-lg"
>
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-accent/40 transition-all duration-200">
{m.poster_path ? (
<img
src={getTmdbImageUrl(m.poster_path, 'w300')}
alt={m.title}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-3 text-2xl font-display">
{m.title?.[0]}
</div>
)}
<div className="absolute top-1.5 left-1.5">
<span className="inline-flex items-center h-[18px] px-1.5 rounded text-[9px] font-bold bg-black/55 backdrop-blur text-white border border-white/10 tabular-nums">
{i + 1}
</span>
</div>
</div>
<p className="text-[12.5px] font-medium text-text-1 truncate mt-2 leading-tight tracking-tight">
{m.title}
</p>
<p className="text-[11px] text-text-3 truncate mt-0.5 inline-flex items-center gap-1">
<Calendar size={9} />
{m.release_date ? m.release_date.slice(0, 4) : 'TBA'}
</p>
</motion.button>
))}
</div>
</div>
</div>
)
}
function CollectionSkeleton() {
const gridCls = usePosterGridClasses()
return (
<div className="pb-12">
<div className="relative h-[50vh] min-h-[380px] -mt-14 overflow-hidden">
<div className="absolute inset-0 skeleton" />
<div className="absolute inset-0 bg-gradient-to-r from-void to-void/30" />
<div className="relative h-full flex items-end pb-12 px-7">
<div className="space-y-3">
<div className="skeleton h-5 w-24 rounded" />
<div className="skeleton h-12 w-96 rounded" />
<div className="skeleton h-3 w-72 rounded" />
</div>
</div>
</div>
<div className="px-7 pt-6">
<div className={gridCls}>
{Array.from({ length: 12 }).map((_, i) => (
<div key={i}>
<div className="skeleton aspect-[2/3] rounded-lg" />
<div className="skeleton h-3 w-3/4 mt-2 rounded" />
<div className="skeleton h-2.5 w-1/2 mt-1 rounded" />
</div>
))}
</div>
</div>
</div>
)
}
+359
View File
@@ -0,0 +1,359 @@
import { useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
useItemDetails,
useLibraryByTmdbId,
useRefreshItem,
useSampleEpisode,
useBulkToggleFavorite,
} from '../hooks/use-jellyfin'
import { useTmdbMovie, useTmdbTvShow, useTmdbCollection } from '../hooks/use-tmdb'
import {
useCinemeta,
useFanartMovie,
useFanartTv,
useTvmazeByImdbId,
useWikiResolve,
useWikiSection,
useWikidataAwards,
useWikidataLocations,
useRottenTomatoes,
} from '../hooks/use-external'
import { pickBestFanartImage } from '../api/fanart'
import DetailHero from '../components/detail/DetailHero'
import DetailStickyBar from '../components/detail/DetailStickyBar'
import type { SectionTab } from '../components/detail/DetailSectionTabs'
import DetailSkeleton from '../components/detail/DetailSkeleton'
import DetailMainSections from '../components/detail/DetailMainSections'
import { usePastSentinel } from '../hooks/use-past-sentinel'
import { parseTmdbRouteId } from '../lib/tmdb-mapping'
import TmdbDetailPage from './TmdbDetailPage'
import { getBestImage, getImageUrl, getStoredServerUrl } from '../api/jellyfin'
import PlaylistView from '../components/detail/PlaylistView'
import FolderView from '../components/detail/FolderView'
import { formatRuntime, regionForUser } from '../lib/format'
import { usePreferencesStore } from '../stores/preferences-store'
export default function DetailPage() {
const { id } = useParams<{ id: string }>()
// Synthetic TMDB ids from discovery rows route to a dedicated TMDB-only
// page so items not in the library get a proper detail view + Request CTA.
const tmdbRoute = parseTmdbRouteId(id)
if (tmdbRoute) return <TmdbDetailPage tmdbId={tmdbRoute.tmdbId} kind={tmdbRoute.kind} />
return <JellyfinDetailPage id={id} />
}
function JellyfinDetailPage({ id }: { id?: string }) {
const { data: item, isLoading } = useItemDetails(id)
const { sentinelRef: stickySentinelRef, past: pastHero } = usePastSentinel()
const toggleFavorite = useBulkToggleFavorite()
const itemType = item?.Type
const tmdbId = item?.ProviderIds?.Tmdb
const imdbId = item?.ProviderIds?.Imdb || null
const tvdbId = item?.ProviderIds?.Tvdb || null
const tmdbMovie = useTmdbMovie(itemType === 'Movie' ? tmdbId : null)
const tmdbTv = useTmdbTvShow(itemType === 'Series' ? tmdbId : null)
const tmdbData = itemType === 'Movie' ? tmdbMovie.data : tmdbTv.data
// External enrichment - all keyless except Fanart.tv which silently no-ops
// when the personal key is empty. Each hook gates on type so we don't fire
// movie endpoints for shows or vice versa.
const fanartMovieQuery = useFanartMovie(itemType === 'Movie' ? (tmdbId || imdbId) : null)
const fanartTvQuery = useFanartTv(itemType === 'Series' ? tvdbId : null)
const cinemetaQuery = useCinemeta(
imdbId,
itemType === 'Movie' ? 'movie' : itemType === 'Series' ? 'series' : undefined,
)
const tvmazeQuery = useTvmazeByImdbId(itemType === 'Series' ? imdbId : null)
// Wikipedia overview fallback - resolved by title+year when neither
// Jellyfin/TMDB nor Cinemeta produced anything substantial. Hook must run
// unconditionally so it goes here, above any early returns.
const baseOverview = item?.Overview || tmdbData?.overview || ''
const overviewIsThin = baseOverview.trim().length < 60
const cinemetaDescription = cinemetaQuery.data?.description || ''
const wikiQuery = useWikiResolve(
item?.Name && item?.ProductionYear
? `${item.Name} ${item.ProductionYear}`
: item?.Name,
!!item && overviewIsThin && !cinemetaDescription,
)
// Wikipedia "Production" section, resolved unconditionally so the trivia
// block can render even when the overview is fine. We need a known title
// first; we always run a quick resolve for that and then ask for the
// section. Cheap because both queries are cached for a week.
const wikiTitleQuery = useWikiResolve(
item?.Name && item?.ProductionYear
? `${item.Name} ${item.ProductionYear}`
: item?.Name,
!!item,
)
const wikiTitle = wikiTitleQuery.data?.title || null
const wikiProductionQuery = useWikiSection(wikiTitle, 'Production')
// Wikidata awards via SPARQL P166. Keyed off the TMDB external_ids
// wikidata_id field which we already pull on movie + tv full responses.
const wikidataId = tmdbData?.external_ids?.wikidata_id || null
const awardsQuery = useWikidataAwards(wikidataId)
const locationsQuery = useWikidataLocations(wikidataId)
const collectionId = tmdbMovie.data?.belongs_to_collection?.id
const tmdbCollection = useTmdbCollection(collectionId)
// Rotten Tomatoes critic + audience scores (Algolia search by title+year,
// same approach Jellyseerr uses). Series ratings work, episode ratings
// would be hit-or-miss so we only fire on Movie / Series.
const rtKind: 'movie' | 'tv' | null =
itemType === 'Movie' ? 'movie' : itemType === 'Series' ? 'tv' : null
const rtYear = itemType === 'Movie'
? (tmdbMovie.data?.release_date ? Number(tmdbMovie.data.release_date.slice(0, 4)) : item?.ProductionYear) || null
: itemType === 'Series'
? (tmdbTv.data?.first_air_date ? Number(tmdbTv.data.first_air_date.slice(0, 4)) : item?.ProductionYear) || null
: null
const rtName =
itemType === 'Movie'
? tmdbMovie.data?.title || item?.Name
: itemType === 'Series'
? tmdbTv.data?.name || item?.Name
: null
const rtQuery = useRottenTomatoes(rtName, rtYear, rtKind)
const prefs = usePreferencesStore()
const region = prefs.region || regionForUser()
const showTmdbRatings = prefs.showTmdbRatings
const libraryMap = useLibraryByTmdbId()
const refresh = useRefreshItem()
const sampleEpisode = useSampleEpisode(id, itemType === 'Series')
// Versions selector state
const sources = (item?.MediaSources || []) as any[]
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null)
const activeSourceId = selectedSourceId || sources[0]?.Id || null
// Pick a regional certification when available (must run before early returns)
const regionalCert = useMemo(() => {
if (itemType === 'Movie') {
const dates = (tmdbMovie.data?.release_dates?.results || []).find(r => r.iso_3166_1 === region)
return dates?.release_dates?.find(d => d.certification)?.certification || null
}
if (itemType === 'Series') {
const r = (tmdbTv.data?.content_ratings?.results || []).find(r => r.iso_3166_1 === region)
return r?.rating || null
}
return null
}, [itemType, tmdbMovie.data, tmdbTv.data, region])
if (isLoading) return <DetailSkeleton />
if (!item) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-6">
<p className="text-text-2 text-[15px] mb-1">Item not found</p>
<p className="text-text-4 text-[12px]">The item you're looking for may have been removed.</p>
</div>
)
}
const serverUrl = getStoredServerUrl()
const jellyfinBackdrop = getBestImage(serverUrl, item, 'backdrop', 1920)
// Fanart backdrop fallback - useful for older / lesser-known items where
// the Jellyfin scraper didn't fetch a backdrop. Voted-best English wins.
const fanartBackdrop = pickBestFanartImage(
fanartMovieQuery.data?.moviebackground
|| fanartTvQuery.data?.showbackground
|| undefined,
)
const backdropUrl = jellyfinBackdrop || fanartBackdrop?.url || null
const posterUrl = getBestImage(serverUrl, item, 'primary', 400)
// Logo priority: Jellyfin Logo (locally curated) > Fanart.tv HD logo
// (cinematic SVG/PNG) > none (falls back to title typography).
const fanartLogo = pickBestFanartImage(
fanartMovieQuery.data?.hdmovielogo
|| fanartMovieQuery.data?.movielogo
|| fanartTvQuery.data?.hdtvlogo
|| fanartTvQuery.data?.clearlogo
|| undefined,
)
const jellyfinLogo =
item.Id && item.ImageTags?.Logo
? getImageUrl(serverUrl, item.Id, 'Logo', 600, item.ImageTags.Logo)
: null
const logoUrl = jellyfinLogo || fanartLogo?.url || null
const progress = item.UserData?.PlayedPercentage
const positionTicks = item.UserData?.PlaybackPositionTicks
const resumeThresholdSec = prefs.resumeThresholdSec
const watchedSec = positionTicks ? Number(positionTicks) / 10_000_000 : 0
// Only offer resume when we've watched past the threshold (and we're not effectively done)
const meetsResumeThreshold = watchedSec >= resumeThresholdSec && (progress ?? 0) < 95
const resumeTime = positionTicks && meetsResumeThreshold ? formatRuntime(Number(positionTicks)) : null
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null
const genres = item.Genres || []
const tmdbCredits = tmdbData?.credits
const cast = tmdbCredits?.cast?.slice(0, 18) || []
const crew = tmdbCredits?.crew || []
const trailer = tmdbData?.videos?.results?.find(
v => v.site === 'YouTube' && v.type === 'Trailer',
)
const hideAdult = prefs.hideAdult
const adultFilter = <T extends { adult?: boolean }>(arr: T[]) =>
hideAdult ? arr.filter(x => !x.adult) : arr
const recommendations = adultFilter((tmdbData?.recommendations?.results || []) as { id: number; adult?: boolean }[])
// Movie + TV similar arrays don't share an exact element type, so widen
// to the adultFilter constraint to keep TS happy across the union.
const similarRaw = itemType === 'Movie'
? tmdbMovie.data?.similar?.results || []
: tmdbTv.data?.similar?.results || []
const similar = adultFilter(similarRaw as { id: number; adult?: boolean }[])
const keywords =
itemType === 'Movie'
? tmdbMovie.data?.keywords?.keywords || []
: tmdbTv.data?.keywords?.results || []
const reviews = tmdbData?.reviews?.results || []
const title = item.Name || 'Untitled'
const year = item.ProductionYear
// Final overview chain (computed values - the source flags + base/thin
// were already established up at the top so the hook ordering is stable).
const overview = baseOverview
|| cinemetaDescription
|| wikiQuery.data?.extract
|| ''
const overviewSource = baseOverview
? null
: cinemetaDescription
? 'IMDB'
: wikiQuery.data
? 'Wikipedia'
: null
const taglineText = tmdbData?.tagline
const rating = regionalCert || item.OfficialRating
const isFavorite = item.UserData?.IsFavorite
const isFolder =
itemType === 'CollectionFolder' ||
itemType === 'ManualPlaylistsFolder' ||
itemType === 'PlaylistsFolder'
if (isFolder) {
return <FolderView itemId={id!} title={item.Name || 'Library'} collectionType={item.CollectionType} />
}
// Playlists get a purpose-built view: hero with Play / Shuffle, item list
// with thumbs and watched status. No tech specs, no episodes section.
if (itemType === 'Playlist') {
return <PlaylistView item={item} serverUrl={serverUrl} />
}
const playUrl = `/play/${item.Id}${activeSourceId && sources.length > 1 ? `?source=${activeSourceId}` : ''}`
const resumeUrl = `/play/${item.Id}?resume=true${activeSourceId && sources.length > 1 ? `&source=${activeSourceId}` : ''}`
const watchProviders =
itemType === 'Movie' ? tmdbMovie.data?.['watch/providers'] : tmdbTv.data?.['watch/providers']
return (
<div className="pb-16">
<DetailStickyBar
visible={pastHero}
title={title}
logoUrl={logoUrl}
posterUrl={posterUrl}
progress={progress}
isFavorite={isFavorite}
playUrl={playUrl}
resumeUrl={resumeUrl}
tmdbId={tmdbId}
imdbId={imdbId}
itemType={itemType}
tabs={detailTabsFor(itemType)}
onFavoriteToggle={
item.Id
? () => toggleFavorite.mutate({ itemIds: [item.Id!], favorite: !isFavorite })
: undefined
}
/>
<DetailHero
item={item}
itemType={itemType ?? ""}
tmdbMovieData={tmdbMovie.data}
tmdbTvData={tmdbTv.data}
tmdbId={tmdbId}
posterUrl={posterUrl}
logoUrl={logoUrl}
backdropUrl={backdropUrl}
title={title}
year={year}
rating={rating}
runtime={runtime}
taglineText={taglineText}
genres={genres}
resumeTime={resumeTime}
progress={progress}
isFavorite={isFavorite}
playUrl={playUrl}
resumeUrl={resumeUrl}
trailer={trailer}
cinemetaData={cinemetaQuery.data}
showTmdbRatings={showTmdbRatings}
sampleEpisode={sampleEpisode}
refresh={refresh}
/>
<div ref={stickySentinelRef} aria-hidden className="h-px -mt-px" />
<DetailMainSections
item={item}
itemId={id}
itemType={itemType ?? ""}
imdbId={imdbId}
tmdbId={tmdbId}
tmdbMovieData={tmdbMovie.data}
tmdbTvData={tmdbTv.data}
tmdbCollectionData={tmdbCollection.data}
cinemetaData={cinemetaQuery.data}
tvmazeData={tvmazeQuery.data}
wikiProduction={wikiProductionQuery.data}
awardsData={awardsQuery.data}
locationsData={locationsQuery.data}
rtData={rtQuery.data}
wikiTitle={wikiTitle}
region={region}
watchProviders={watchProviders}
cast={cast}
crew={crew}
keywords={keywords}
reviews={reviews}
videos={tmdbData?.videos?.results}
recommendations={recommendations}
similar={similar}
libraryMap={libraryMap.data}
overview={overview}
overviewSource={overviewSource}
sources={sources}
activeSourceId={activeSourceId}
onSourceChange={setSelectedSourceId}
/>
</div>
)
}
function detailTabsFor(itemType: string | undefined): SectionTab[] {
return [
{ id: 'detail-overview', label: 'Overview' },
...(itemType === 'Series' ? [{ id: 'detail-episodes', label: 'Episodes' } as SectionTab] : []),
{ id: 'detail-reception', label: 'Reception' },
{ id: 'detail-cast', label: 'Cast & crew' },
{ id: 'detail-trivia', label: 'Trivia' },
{ id: 'detail-reviews', label: 'Reviews' },
{ id: 'detail-more', label: 'More like this' },
]
}
+192
View File
@@ -0,0 +1,192 @@
import { useState } from 'react'
import { useHasTmdbKey } from '../hooks/use-external'
import LazyMount from '../components/ui/LazyMount'
import { usePreferencesStore } from '../stores/preferences-store'
import { regionForUser } from '../lib/format'
import DiscoverFilters, {
DEFAULT_FILTERS,
hasAnyActiveFilters,
type DiscoverFilterState,
} from '../components/discover/DiscoverFilters'
import TonightHero from '../components/discover/TonightHero'
import { MoodChips, MoodRow } from '../components/discover/MoodChips'
import Roulette from '../components/discover/Roulette'
import DecadeStrip from '../components/discover/DecadeStrip'
import CanonicalLists from '../components/discover/CanonicalLists'
import OnThisDay from '../components/discover/OnThisDay'
import LibraryGapFinder from '../components/discover/LibraryGapFinder'
import {
BrowseSection,
genreTiles,
languageTiles,
studioTiles,
networkTiles,
type BrowseKey,
} from '../components/discover/BrowseGrid'
import { NoKey, Hero } from './discover/chrome'
import { FilteredGrid } from './discover/filtered-grid'
import {
TrendingDayRow,
TrendingWeekRow,
PopularMoviesRow,
PopularTvRow,
UpcomingMoviesRow,
TopRatedMoviesRow,
TopRatedTvRow,
CultClassicsRow,
} from './discover/rows'
/**
* Discover page - intent-driven entry to TMDB content the user doesn't
* already have.
*
* Layout:
* 1. Compact page header
* 2. Sticky filter bar (movie/tv + advanced filters)
* 3. Editorial spotlight - one big "pick of the day"
* 4. Mood chips - 10 vibes that resolve to a single content row
* 5. 3-4 curated rows (Trending / Acclaimed / Coming soon / etc.)
* 6. Browse-by sections: genre, language, studio, network - compact
* tile grids with inline expansion when a tile is clicked
*
* Active filters take over the page with the existing FilteredGrid - that
* mode is unchanged so the filter workflow stays familiar.
*/
export default function DiscoverPage() {
const hasTmdb = useHasTmdbKey()
const prefs = usePreferencesStore()
const region = prefs.region || regionForUser()
const [filters, setFilters] = useState<DiscoverFilterState>(() => ({
...DEFAULT_FILTERS,
watchRegion: region,
}))
const [moodId, setMoodId] = useState<string | null>(null)
const [decade, setDecade] = useState<string | null>(null)
const [expanded, setExpanded] = useState<BrowseKey | null>(null)
if (!hasTmdb) return <NoKey />
const filterMode = hasAnyActiveFilters(filters)
const kind = filters.kind
// Reset the mood / expansion when toggling movie<->tv since both are
// kind-scoped and the previously-active mood may not exist on the
// other side.
function onFiltersChange(next: DiscoverFilterState) {
if (next.kind !== filters.kind) {
setMoodId(null)
setDecade(null)
setExpanded(null)
}
setFilters(next)
}
return (
<div className="pb-12" style={{ isolation: 'isolate' }}>
<Hero />
<div
className="sticky top-0 py-3 -mt-1 mb-3 border-b border-border/60"
style={{ zIndex: 25, backgroundColor: 'var(--color-void)' }}
>
<DiscoverFilters filters={filters} onChange={onFiltersChange} region={region} />
</div>
{filterMode ? (
<FilteredGrid filters={filters} />
) : (
<>
<LazyMount><TonightHero kind={kind} /></LazyMount>
<div className="px-7 mb-2 flex items-center justify-end">
<Roulette kind={kind} moodId={moodId} />
</div>
<MoodChips activeId={moodId} onChange={setMoodId} kind={kind} />
{moodId && (
<div className="mb-4">
<MoodRow moodId={moodId} kind={kind} />
</div>
)}
<DecadeStrip kind={kind} active={decade} onChange={setDecade} />
<div className="relative z-10">
{kind === 'movie' ? (
<>
<LazyMount><TrendingDayRow /></LazyMount>
<LazyMount><PopularMoviesRow /></LazyMount>
<LazyMount><UpcomingMoviesRow region={region} /></LazyMount>
<LazyMount><TopRatedMoviesRow /></LazyMount>
<LazyMount><CultClassicsRow /></LazyMount>
</>
) : (
<>
<LazyMount><TrendingWeekRow /></LazyMount>
<LazyMount><PopularTvRow /></LazyMount>
<LazyMount><TopRatedTvRow /></LazyMount>
</>
)}
</div>
{kind === 'movie' && <LazyMount><OnThisDay /></LazyMount>}
{kind === 'movie' && <LazyMount><LibraryGapFinder /></LazyMount>}
{kind === 'movie' && <LazyMount><CanonicalLists /></LazyMount>}
<div className="mt-4 mb-2 px-7">
<div className="flex items-center gap-3 text-text-4">
<span className="flex-1 h-px bg-border" />
<span className="text-[10.5px] uppercase tracking-[0.2em] font-semibold">Browse by</span>
<span className="flex-1 h-px bg-border" />
</div>
</div>
<BrowseSection
eyebrow="By genre"
title="Genres"
subtitle="Top of each genre, missing from your shelves"
tiles={genreTiles()}
expanded={expanded}
onSelect={setExpanded}
/>
{kind === 'movie' && (
<BrowseSection
eyebrow="By language"
title="Around the world"
subtitle="Top-rated cinema by original language"
tiles={languageTiles()}
expanded={expanded}
onSelect={setExpanded}
/>
)}
{kind === 'movie' && (
<BrowseSection
eyebrow="By studio"
title="From the studios"
subtitle="Films grouped by who made them"
tiles={studioTiles()}
expanded={expanded}
onSelect={setExpanded}
/>
)}
{kind === 'tv' && (
<BrowseSection
eyebrow="By network"
title="On the networks"
subtitle="Shows grouped by where they air"
tiles={networkTiles()}
expanded={expanded}
onSelect={setExpanded}
/>
)}
</>
)}
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Download, Trash2, Play, AlertCircle, Check, Clock } from '../lib/icons'
import { useDownloads } from '../stores/downloads-store'
import { isTauri } from '../lib/tauri'
export default function DownloadsPage() {
const navigate = useNavigate()
const items = useDownloads(s => s.items)
const remove = useDownloads(s => s.remove)
const clearCompleted = useDownloads(s => s.clearCompleted)
const grouped = useMemo(() => {
const active = items.filter(i => i.status === 'queued' || i.status === 'downloading')
const done = items.filter(i => i.status === 'done')
const error = items.filter(i => i.status === 'error')
return { active, done, error }
}, [items])
return (
<div className="px-7 pt-4 pb-12">
<header className="mb-7 flex items-end justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Offline</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Downloads
</h1>
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
{isTauri
? 'Items saved locally for offline playback.'
: 'Downloads are stored in the browser cache for offline playback.'}
</p>
</div>
{grouped.done.length > 0 && (
<button
onClick={clearCompleted}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-error/50 hover:text-error transition-all focus-ring"
>
<Trash2 size={13} stroke={2} />
Clear completed
</button>
)}
</header>
{items.length === 0 && (
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
<div className="relative w-14 h-14 mx-auto mb-4">
<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">
<Download size={20} className="text-accent" />
</div>
</div>
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No downloads yet</p>
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
Use the download button in the player to save items for offline viewing.
</p>
</div>
)}
<div className="space-y-8">
{grouped.active.length > 0 && (
<section>
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
<Clock size={13} className="text-accent" /> In progress
</h2>
<div className="space-y-2">
{grouped.active.map(item => (
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
))}
</div>
</section>
)}
{grouped.done.length > 0 && (
<section>
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
<Check size={13} className="text-success" /> Completed
</h2>
<div className="space-y-2">
{grouped.done.map(item => (
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} onPlay={() => navigate(`/play/${item.itemId}`)} />
))}
</div>
</section>
)}
{grouped.error.length > 0 && (
<section>
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
<AlertCircle size={13} className="text-error" /> Failed
</h2>
<div className="space-y-2">
{grouped.error.map(item => (
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
))}
</div>
</section>
)}
</div>
</div>
)
}
function DownloadRow({
item,
onRemove,
onPlay,
}: {
item: import('../stores/downloads-store').DownloadItem
onRemove: () => void
onPlay?: () => void
}) {
return (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-3 p-3 rounded-xl bg-elevated/30 ring-1 ring-border hover:ring-border-strong transition"
>
{item.posterUrl ? (
<img src={item.posterUrl} alt="" className="w-10 aspect-[2/3] rounded object-cover shrink-0 bg-void" loading="lazy" />
) : (
<div className="w-10 aspect-[2/3] rounded bg-void grid place-items-center shrink-0">
<Download size={14} className="text-text-4" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-[13px] text-text-1 font-medium truncate">{item.name}</p>
<div className="flex items-center gap-2 mt-1">
{item.status === 'downloading' && (
<>
<div className="flex-1 h-1.5 rounded-full bg-elevated overflow-hidden max-w-[160px]">
<div className="h-full bg-accent transition-[width] duration-300" style={{ width: `${item.progress}%` }} />
</div>
<span className="text-[10.5px] text-text-3 tabular-nums">{item.progress}%</span>
</>
)}
{item.status === 'done' && <span className="text-[10.5px] text-success font-medium">Ready</span>}
{item.status === 'error' && <span className="text-[10.5px] text-error font-medium truncate">{item.error || 'Failed'}</span>}
{item.status === 'queued' && <span className="text-[10.5px] text-text-3 font-medium">Queued</span>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{onPlay && (
<button
onClick={onPlay}
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-accent hover:bg-elevated transition focus-ring"
aria-label="Play"
>
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={onRemove}
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-error hover:bg-elevated transition focus-ring"
aria-label="Remove"
>
<Trash2 size={13} stroke={2} />
</button>
</div>
</motion.div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Tv, AlertCircle } from '../lib/icons'
import { useLibraryItems } from '../hooks/use-jellyfin'
import { getBestImage, getStoredServerUrl } from '../api/jellyfin'
import PosterCard from '../components/ui/PosterCard'
export default function DuplicatesPage() {
const navigate = useNavigate()
const serverUrl = getStoredServerUrl()
const { data, isLoading } = useLibraryItems(undefined, {
includeItemTypes: ['Movie', 'Series'],
sortBy: ['SortName'],
sortOrder: ['Ascending'],
limit: 5000,
})
const items = data?.Items || []
const duplicates = useMemo(() => {
// Group by TMDB ID first (most reliable)
const byTmdb = new Map<string, typeof items>()
const byNameYear = new Map<string, typeof items>()
for (const item of items) {
const tmdb = item.ProviderIds?.Tmdb
if (tmdb) {
const list = byTmdb.get(String(tmdb)) || []
list.push(item)
byTmdb.set(String(tmdb), list)
continue
}
// Fallback: normalized name + year + type
const key = `${(item.Name || '').toLowerCase().trim()}|${item.ProductionYear || 'none'}|${item.Type}`
const list = byNameYear.get(key) || []
list.push(item)
byNameYear.set(key, list)
}
const groups: { key: string; label: string; items: typeof items }[] = []
for (const [tmdb, list] of byTmdb) {
if (list.length >= 2) {
const first = list[0]
groups.push({
key: `tmdb-${tmdb}`,
label: `${first.Name} (${first.ProductionYear || '?'})`,
items: list,
})
}
}
for (const [, list] of byNameYear) {
if (list.length >= 2) {
const first = list[0]
groups.push({
key: `name-${first.Id}`,
label: `${first.Name} (${first.ProductionYear || '?'}) - no TMDB match`,
items: list,
})
}
}
return groups.sort((a, b) => a.label.localeCompare(b.label))
}, [items])
return (
<div className="px-7 pt-4 pb-12">
<header className="mb-7">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Tools</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Duplicate finder
</h1>
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
Scans your library for items that appear more than once - either by shared TMDB ID or by matching name + year.
</p>
</header>
{isLoading && items.length === 0 && (
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
<p className="text-[13px] text-text-2 font-medium">Scanning your library...</p>
</div>
)}
{duplicates.length === 0 && !isLoading && (
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
<div className="relative w-14 h-14 mx-auto mb-4">
<div className="absolute inset-0 rounded-full bg-success/10 blur-xl" />
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-success/30 grid place-items-center">
<AlertCircle size={20} className="text-success" />
</div>
</div>
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No duplicates found</p>
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
Every movie and series in your library has a unique identity. Nice and tidy.
</p>
</div>
)}
<div className="space-y-8">
{duplicates.map((group, gi) => (
<section key={group.key}>
<div className="flex items-center gap-2 mb-3">
{group.items[0]?.Type === 'Series' ? (
<Tv size={13} className="text-accent" />
) : (
<Film size={13} className="text-accent" />
)}
<h2 className="text-[14px] font-semibold text-text-1 tracking-tight">{group.label}</h2>
<span className="text-[11px] text-text-4 tabular-nums">{group.items.length} copies</span>
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
{group.items.map((item, i) => (
<motion.div
key={item.Id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
>
<PosterCard
item={item}
priority={gi < 3 && i < 6}
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
/>
</motion.div>
))}
</div>
</section>
))}
</div>
</div>
)
}
+202
View File
@@ -0,0 +1,202 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useHomeData, useLibraryItems } from '../hooks/use-jellyfin'
import { useTmdbTrending } from '../hooks/use-tmdb'
import ContentRow from '../components/ui/ContentRow'
import LazyMount, { RowBoundary } from '../components/ui/LazyMount'
import { getTmdbImageUrl } from '../api/tmdb'
import { usePreferencesStore } from '../stores/preferences-store'
import type { BaseItemDto } from '../api/types'
import { FeaturedCarousel } from './home/home-hero'
import { dailyBucket, seededShuffle, HomeSkeleton, EmptyHome } from './home/home-utils'
import { DecadeRow, GenreRow } from './home/rows/library'
import { HomeSections } from './home/rows/composite'
const FEATURED_GENRES = [
{ name: 'Action', subtitle: 'Bullets, fists, explosions' },
{ name: 'Drama', subtitle: 'Stories that hit deep' },
{ name: 'Comedy', subtitle: 'Light watching' },
{ name: 'Science Fiction', subtitle: 'Beyond the possible' },
{ name: 'Thriller', subtitle: 'Edge-of-your-seat tension' },
{ name: 'Horror', subtitle: 'Lights off' },
{ name: 'Documentary', subtitle: 'Real stories' },
{ name: 'Animation', subtitle: 'Drawn to perfection' },
]
export default function HomePage() {
const navigate = useNavigate()
const { continueWatching, nextUp, recentlyAdded } = useHomeData()
const trending = useTmdbTrending('all', 'week')
const hideAdult = usePreferencesStore(s => s.hideAdult)
const prefs = usePreferencesStore()
const topRated = useLibraryItems(undefined, {
includeItemTypes: ['Movie', 'Series'],
sortBy: ['CommunityRating'],
sortOrder: ['Descending'],
minCommunityRating: 7,
limit: 20,
})
const surpriseMe = useLibraryItems(undefined, {
includeItemTypes: ['Movie', 'Series'],
sortBy: ['Random'],
sortOrder: ['Descending'],
limit: 20,
})
const recentlyPlayed = useLibraryItems(undefined, {
includeItemTypes: ['Movie', 'Series'],
sortBy: ['DatePlayed'],
sortOrder: ['Descending'],
filters: ['IsPlayed'],
limit: 14,
})
const featuredPool = useMemo<BaseItemDto[]>(() => {
const pool: BaseItemDto[] = []
const isFeatureWorthy = (item: BaseItemDto) =>
(item.Type === 'Movie' || item.Type === 'Series') &&
!!item.BackdropImageTags?.length
// Bias toward continue watching first (user is engaged with these)
for (const item of continueWatching.data || []) if (isFeatureWorthy(item)) pool.push(item)
for (const item of recentlyAdded.data || []) if (isFeatureWorthy(item)) pool.push(item)
for (const item of topRated.data?.Items || []) if (isFeatureWorthy(item)) pool.push(item)
const seen = new Set<string>()
const unique = pool.filter(p => {
if (!p.Id || seen.has(p.Id)) return false
seen.add(p.Id)
return true
})
// Day-bucketed shuffle so the order changes a few times a day but stays
// stable within a session, then clamp to 8 slides max.
return seededShuffle(unique, dailyBucket()).slice(0, 8)
}, [continueWatching.data, recentlyAdded.data, topRated.data])
const hasAnyData =
(continueWatching.data?.length ?? 0) > 0 ||
(nextUp.data?.length ?? 0) > 0 ||
(recentlyAdded.data?.length ?? 0) > 0
const allLoading =
continueWatching.isLoading && nextUp.isLoading && recentlyAdded.isLoading
if (allLoading) return <HomeSkeleton />
if (!hasAnyData) return <EmptyHome />
return (
<div className="pb-12">
{featuredPool.length > 0 && <FeaturedCarousel items={featuredPool} navigate={navigate} />}
<div className="-mt-4 relative z-10">
{prefs.home.show.continueWatching && continueWatching.data && continueWatching.data.length > 0 && (
<RowBoundary>
<ContentRow
title="Continue watching"
subtitle="Pick up where you left off"
items={continueWatching.data}
aspect="landscape"
/>
</RowBoundary>
)}
{prefs.home.show.nextUp && nextUp.data && nextUp.data.length > 0 && (
<RowBoundary>
<ContentRow
title="Next up"
subtitle="The next episodes for shows you watch"
items={nextUp.data}
/>
</RowBoundary>
)}
{prefs.home.show.recentlyAdded && recentlyAdded.data && recentlyAdded.data.length > 0 && (
<RowBoundary>
<ContentRow
title="Recently added"
subtitle="Fresh from your library"
items={recentlyAdded.data}
/>
</RowBoundary>
)}
{prefs.home.show.trendingWeek && trending.data?.results && trending.data.results.length > 0 && (() => {
const filtered = trending.data.results.filter(r => !hideAdult || !r.adult)
if (filtered.length === 0) return null
return (
<RowBoundary>
<ContentRow
title="Trending this week"
subtitle="What the world is watching on TMDB"
items={filtered.map(r => ({
Id: `tmdb-${r.id}`,
Name: r.title || r.name,
Type: r.media_type === 'tv' ? 'Series' : 'Movie',
ProductionYear: r.release_date
? parseInt(r.release_date)
: r.first_air_date
? parseInt(r.first_air_date)
: undefined,
ImageTags: {},
ProviderIds: { Tmdb: String(r.id) },
_tmdbPoster: r.poster_path ? getTmdbImageUrl(r.poster_path, 'w342') : undefined,
} as any as BaseItemDto))}
/>
</RowBoundary>
)
})()}
{prefs.home.show.topRated && topRated.data?.Items && topRated.data.Items.length > 0 && (
<RowBoundary>
<ContentRow
title="Top rated in your library"
subtitle="The cream of the crop"
items={topRated.data.Items}
/>
</RowBoundary>
)}
{prefs.home.show.watchedRecently && recentlyPlayed.data?.Items && recentlyPlayed.data.Items.length > 0 && (
<RowBoundary>
<ContentRow
title="Watched recently"
subtitle="Revisit something you've finished"
items={recentlyPlayed.data.Items}
/>
</RowBoundary>
)}
{prefs.home.show.surpriseMe && surpriseMe.data?.Items && surpriseMe.data.Items.length > 0 && (
<RowBoundary>
<ContentRow
title="Surprise me"
subtitle="A random shake-up from your library"
items={surpriseMe.data.Items}
/>
</RowBoundary>
)}
<HomeSections />
{prefs.home.show.decadeRows && (
<>
<LazyMount><DecadeRow decade={1990} /></LazyMount>
<LazyMount><DecadeRow decade={2000} /></LazyMount>
<LazyMount><DecadeRow decade={2010} /></LazyMount>
</>
)}
{prefs.home.show.featuredGenres &&
FEATURED_GENRES.map(g => (
<LazyMount key={g.name}>
<GenreRow genre={g.name} subtitle={g.subtitle} />
</LazyMount>
))}
</div>
</div>
)
}
+422
View File
@@ -0,0 +1,422 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { ArrowDownAZ, Calendar, Shuffle, Film, Tv, Filter, X, Star } from '../lib/icons'
import { useLibraries, useLibraryItems } from '../hooks/use-jellyfin'
import PosterCard from '../components/ui/PosterCard'
import Select, { type SelectOption } from '../components/ui/Select'
import { resolutionLabel, videoRangeLabel } from '../lib/jellyfin-meta'
import { iconForGenre } from '../lib/genre-icons'
import { usePosterGridClasses } from '../lib/density'
import { type SavedSearchFilters } from '../stores/saved-searches-store'
import { useLibrarySelection } from '../stores/library-selection-store'
import BatchActionsBar from '../components/ui/BatchActionsBar'
import { WatchedPills, SavedSearchMenu } from './library/controls'
import { SaveSearchModal, SurpriseMeModal } from './library/modals'
import { EmptyLibrary, PosterGridSkeleton } from './library/states'
interface Props {
type: 'movies' | 'shows'
}
const SORT_OPTIONS = [
{ value: 'SortName', label: 'A - Z', icon: ArrowDownAZ },
{ value: 'DateCreated', label: 'Recently added', icon: Calendar },
{ value: 'PremiereDate', label: 'Release date', icon: Calendar },
{ value: 'CommunityRating', label: 'Rating', icon: Star },
{ value: 'Random', label: 'Random', icon: Shuffle },
]
const COLLECTION_TYPE_MAP: Record<string, string> = {
movies: 'movies',
shows: 'tvshows',
}
const ITEM_TYPE_MAP: Record<string, string[]> = {
movies: ['Movie'],
shows: ['Series'],
}
const DECADE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '__any__', label: 'Any decade' },
{ value: '2020', label: '2020s' },
{ value: '2010', label: '2010s' },
{ value: '2000', label: '2000s' },
{ value: '1990', label: '1990s' },
{ value: '1980', label: '1980s' },
{ value: '1970', label: '1970s' },
{ value: '1960', label: '1960s' },
{ value: '1950', label: '1950s' },
{ value: '1900', label: 'Pre-1950' },
]
export default function LibraryPage({ type }: Props) {
const [sortBy, setSortBy] = useState('SortName')
const [genreFilter, setGenreFilter] = useState<string | null>(null)
const [decade, setDecade] = useState<number | null>(null)
const [watchedFilter, setWatchedFilter] = useState<'any' | 'played' | 'unplayed'>('any')
const [only4K, setOnly4K] = useState(false)
const [onlyHdr, setOnlyHdr] = useState(false)
const [surpriseOpen, setSurpriseOpen] = useState(false)
const [saveOpen, setSaveOpen] = useState(false)
const [layoutMode, setLayoutMode] = useState<'grid' | 'map'>(() => {
if (typeof window === 'undefined') return 'grid'
return (localStorage.getItem(`lib_layout:${type}`) as 'grid' | 'map') || 'grid'
})
const [mapShowAll, setMapShowAll] = useState(false)
const navigate = useNavigate()
const gridCls = usePosterGridClasses()
const mapGridCls = 'grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-12 xl:grid-cols-14 2xl:grid-cols-16 gap-2'
const selected = useLibrarySelection(s => s.selected)
const toggleSelect = useLibrarySelection(s => s.toggle)
const clearSelection = useLibrarySelection(s => s.clear)
function changeLayoutMode(next: 'grid' | 'map') {
setLayoutMode(next)
try { localStorage.setItem(`lib_layout:${type}`, next) } catch { /* noop */ }
}
// Clear any in-progress selection when navigating away from the page
// or pressing Escape. Esc is the same dismiss affordance the playlist
// view uses, so the muscle memory carries.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && selected.size > 0) {
clearSelection()
}
}
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
}
}, [selected.size, clearSelection])
useEffect(() => {
return () => clearSelection()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const { data: libraries } = useLibraries()
const collectionType = COLLECTION_TYPE_MAP[type]
const library = libraries?.find(l => l.CollectionType === collectionType)
const parentId = library?.Id
const includeItemTypes = ITEM_TYPE_MAP[type]
const filtersForApi = useMemo(() => {
const f: string[] = []
if (watchedFilter === 'played') f.push('IsPlayed')
else if (watchedFilter === 'unplayed') f.push('IsUnplayed')
return f.length > 0 ? f : undefined
}, [watchedFilter])
const yearsForApi = useMemo(() => {
if (decade == null) return undefined
if (decade === 1900) {
const arr: number[] = []
for (let y = 1900; y < 1950; y++) arr.push(y)
return arr
}
return Array.from({ length: 10 }, (_, i) => decade + i)
}, [decade])
const sortOrder = useMemo(() => {
if (sortBy === 'Random') return ['Descending']
if (sortBy === 'CommunityRating' || sortBy === 'DateCreated' || sortBy === 'PremiereDate') {
return ['Descending']
}
return ['Ascending']
}, [sortBy])
const { data, isLoading } = useLibraryItems(parentId, {
sortBy: [sortBy],
sortOrder,
includeItemTypes,
filters: filtersForApi,
years: yearsForApi,
limit: 500,
})
const allItems = useMemo(() => data?.Items || [], [data?.Items])
const allGenres = useMemo(() => {
const set = new Set<string>()
for (const it of allItems) {
for (const g of it.Genres || []) set.add(g)
}
return [...set].sort((a, b) => a.localeCompare(b))
}, [allItems])
const items = useMemo(() => {
return allItems.filter(it => {
if (genreFilter && !(it.Genres || []).includes(genreFilter)) return false
if (only4K && resolutionLabel(it as any) !== '4K') return false
if (onlyHdr && !videoRangeLabel(it as any)) return false
return true
})
}, [allItems, genreFilter, only4K, onlyHdr])
const totalCount = items.length
const filtered = !!(genreFilter || only4K || onlyHdr || decade != null || watchedFilter !== 'any')
const Icon = type === 'movies' ? Film : Tv
const currentFilters: SavedSearchFilters = {
sortBy,
watched: watchedFilter,
genre: genreFilter,
decade,
only4K,
onlyHdr,
}
function applySaved(filters: SavedSearchFilters) {
setSortBy(filters.sortBy)
setWatchedFilter(filters.watched)
setGenreFilter(filters.genre)
setDecade(filters.decade)
setOnly4K(filters.only4K)
setOnlyHdr(filters.onlyHdr)
}
function clearAll() {
setGenreFilter(null)
setDecade(null)
setWatchedFilter('any')
setOnly4K(false)
setOnlyHdr(false)
}
return (
<div className="px-7 pt-4 pb-12">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-7">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
</div>
<div className="flex items-baseline gap-3">
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
{type === 'movies' ? 'Movies' : 'TV shows'}
</h1>
{totalCount > 0 && (
<span className="text-[12px] text-text-4 tabular-nums">
{totalCount.toLocaleString()} {totalCount === 1 ? 'item' : 'items'}
</span>
)}
</div>
</div>
{/* Sort chips */}
<div className="flex items-center gap-1.5 p-1 bg-elevated/50 border border-border rounded-lg w-fit">
{SORT_OPTIONS.map(opt => {
const isActive = sortBy === opt.value
return (
<button
key={opt.value}
onClick={() => setSortBy(opt.value)}
className={`relative h-7 px-2.5 rounded-md text-[11.5px] font-medium tracking-tight transition-colors duration-150 flex items-center gap-1.5 focus-ring ${
isActive ? 'text-void' : 'text-text-3 hover:text-text-1'
}`}
>
{isActive && (
<motion.span
layoutId="sort-active"
className="absolute inset-0 bg-accent rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<opt.icon size={12} className="relative" strokeWidth={2.25} />
<span className="relative hidden sm:inline">{opt.label}</span>
</button>
)
})}
</div>
</div>
{/* Filters */}
<div className="mb-6 flex items-center gap-2 flex-wrap">
<WatchedPills value={watchedFilter} onChange={setWatchedFilter} />
<button
onClick={() => setOnly4K(o => !o)}
className={`inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[11px] font-bold uppercase tracking-wider transition-all duration-150 focus-ring ${
only4K
? 'bg-cool/25 text-cool border border-cool/40'
: 'bg-elevated/50 text-text-3 border border-border hover:text-text-1 hover:border-border-hover'
}`}
>
4K
</button>
<button
onClick={() => setOnlyHdr(o => !o)}
className={`inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[11px] font-bold uppercase tracking-wider transition-all duration-150 focus-ring ${
onlyHdr
? 'bg-amber-500/25 text-amber-200 border border-amber-400/40'
: 'bg-elevated/50 text-text-3 border border-border hover:text-text-1 hover:border-border-hover'
}`}
>
HDR/DV
</button>
<Select
size="sm"
ariaLabel="Filter by decade"
triggerIcon={<Calendar size={11} stroke={2} />}
value={decade == null ? '__any__' : String(decade)}
onChange={v => setDecade(v === '__any__' ? null : Number(v))}
width="min-w-[120px]"
options={DECADE_OPTIONS as SelectOption<string>[]}
/>
{allGenres.length > 0 && (
<Select
size="sm"
ariaLabel="Filter by genre"
triggerIcon={<Filter size={11} stroke={2} />}
value={genreFilter ?? '__all__'}
onChange={v => setGenreFilter(v === '__all__' ? null : v)}
width="min-w-[160px]"
options={[
{ value: '__all__', label: 'All genres', muted: true } as SelectOption<string>,
...allGenres.map<SelectOption<string>>(g => ({
value: g,
label: g,
icon: iconForGenre(g),
})),
]}
/>
)}
{filtered && (
<button
onClick={clearAll}
className="inline-flex items-center gap-1 h-7 px-2 rounded-md text-[11px] text-text-3 hover:text-text-1 transition-colors focus-ring"
>
<X size={11} />
Clear
</button>
)}
<SavedSearchMenu
scope={type}
onApply={applySaved}
onSave={() => setSaveOpen(true)}
/>
<button
onClick={() => setSurpriseOpen(true)}
disabled={items.length === 0}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md text-[11.5px] font-medium tracking-tight bg-accent/10 text-accent ring-1 ring-accent/30 hover:bg-accent/15 transition disabled:opacity-40 focus-ring"
>
<Shuffle size={11} stroke={2.25} />
Surprise me
</button>
{filtered && (
<span className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-text-4">
<Filter size={10} className="text-accent" />
<span className="tabular-nums">{totalCount}</span> match{totalCount === 1 ? '' : 'es'}
</span>
)}
</div>
{/* Layout toggle - sits next to the matches counter */}
<div className="flex items-center justify-end gap-1 mb-3 -mt-3">
<span className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-4 mr-1">
View
</span>
<div className="inline-flex items-center bg-elevated/40 ring-1 ring-border rounded-md p-0.5">
<button
onClick={() => changeLayoutMode('grid')}
className={`h-6 px-2.5 rounded text-[11px] font-medium tracking-tight transition focus-ring ${
layoutMode === 'grid' ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
}`}
>
Grid
</button>
<button
onClick={() => changeLayoutMode('map')}
className={`h-6 px-2.5 rounded text-[11px] font-medium tracking-tight transition focus-ring ${
layoutMode === 'map' ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
}`}
>
Map
</button>
</div>
</div>
{/* Grid */}
{isLoading ? (
<PosterGridSkeleton />
) : items.length === 0 ? (
<EmptyLibrary type={type} Icon={Icon} />
) : (
<AnimatePresence mode="popLayout">
<motion.div
key={`${sortBy}-${layoutMode}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className={layoutMode === 'map' ? mapGridCls : gridCls}
onClick={(e) => {
// Click on the grid background (not on a card) clears the selection.
if (e.target === e.currentTarget && selected.size > 0) clearSelection()
}}
>
{(layoutMode === 'map' && !mapShowAll ? items.slice(0, 240) : items).map((item, i) => (
<motion.div
key={item.Id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.012, 0.3), ease: [0.16, 1, 0.3, 1] }}
>
<PosterCard
item={item}
priority={i < 12}
selected={item.Id ? selected.has(item.Id) : false}
onToggleSelect={item.Id ? () => toggleSelect(item.Id!) : undefined}
libraryContext
compact={layoutMode === 'map'}
onClick={() => {
if (selected.size > 0) {
// While in selection mode, plain clicks toggle membership too.
if (item.Id) toggleSelect(item.Id)
return
}
if (item.Id) navigate(`/item/${item.Id}`)
}}
/>
</motion.div>
))}
</motion.div>
</AnimatePresence>
)}
{layoutMode === 'map' && !mapShowAll && items.length > 240 && (
<div className="mt-6 text-center">
<button
onClick={() => setMapShowAll(true)}
className="inline-flex items-center gap-2 h-9 px-4 rounded-full bg-elevated/60 ring-1 ring-border hover:ring-accent/40 hover:text-accent text-[12px] text-text-2 font-medium tracking-tight transition focus-ring"
>
Load all {items.length} items
</button>
</div>
)}
<BatchActionsBar />
<SurpriseMeModal
open={surpriseOpen}
items={items}
onClose={() => setSurpriseOpen(false)}
/>
<SaveSearchModal
open={saveOpen}
scope={type}
filters={currentFilters}
onClose={() => setSaveOpen(false)}
/>
</div>
)
}
+268
View File
@@ -0,0 +1,268 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Radio, CalendarEvent, FileVideo, Play, Clock } from '../lib/icons'
import { useLiveTvInfo, useLiveTvChannels, useLiveTvRecordings, useLiveTvTimers } from '../hooks/use-jellyfin'
import { jellyfinClient, getImageUrl } from '../api/jellyfin'
type Tab = 'channels' | 'recordings' | 'scheduled'
/**
* Live TV / DVR landing page. Three tabs surfaced off Jellyfin's
* LiveTv API:
* - Channels (with current-program inline)
* - Recordings (past + in-progress)
* - Scheduled timers
*
* If Live TV isn't configured server-side, the page shows a friendly
* placeholder instead of empty lists.
*/
export default function LiveTvPage() {
const info = useLiveTvInfo()
const [tab, setTab] = useState<Tab>('channels')
const enabled = info.data?.IsEnabled !== false
const noTuners = !info.data?.Services?.length
if (!info.isLoading && (!enabled || noTuners)) {
return (
<div className="px-7 pt-6 pb-12">
<Header />
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center max-w-xl mx-auto mt-10">
<Radio size={28} className="text-text-3 mx-auto mb-3" />
<p className="text-[14px] text-text-1 font-semibold mb-1.5">Live TV isn't set up on this server</p>
<p className="text-[12.5px] text-text-3 leading-relaxed">
Add a tuner and a guide provider in the Jellyfin dashboard under Live TV. Once configured, your
channels and DVR recordings will show up here.
</p>
</div>
</div>
)
}
return (
<div className="px-7 pt-6 pb-12">
<Header />
<div className="flex items-center gap-1 mb-6 border-b border-border">
<TabButton active={tab === 'channels'} onClick={() => setTab('channels')} icon={<Radio size={14} />}>
Channels
</TabButton>
<TabButton active={tab === 'recordings'} onClick={() => setTab('recordings')} icon={<FileVideo size={14} />}>
Recordings
</TabButton>
<TabButton active={tab === 'scheduled'} onClick={() => setTab('scheduled')} icon={<CalendarEvent size={14} />}>
Scheduled
</TabButton>
</div>
{tab === 'channels' && <ChannelsList />}
{tab === 'recordings' && <RecordingsList />}
{tab === 'scheduled' && <ScheduledList />}
</div>
)
}
function Header() {
return (
<header className="mb-6">
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
Live TV
</p>
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
Channels & recordings
</h1>
</header>
)
}
function TabButton({
active,
onClick,
children,
icon,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
icon: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`relative px-4 h-10 inline-flex items-center gap-2 text-[12.5px] font-medium tracking-tight transition-colors focus-ring ${
active ? 'text-text-1' : 'text-text-3 hover:text-text-1'
}`}
>
{icon}
{children}
{active && (
<motion.span
layoutId="livetv-tab"
className="absolute -bottom-px left-0 right-0 h-[2px] bg-accent rounded-t"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
</button>
)
}
function ChannelsList() {
const navigate = useNavigate()
const serverUrl = jellyfinClient.getAuthState()?.serverUrl || ''
const { data: channels = [], isLoading } = useLiveTvChannels()
if (isLoading) return <Skeleton />
if (channels.length === 0) {
return <EmptyMsg>No channels available right now.</EmptyMsg>
}
return (
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
{channels.map(ch => {
const program = (ch as any).CurrentProgram as { Name?: string; StartDate?: string; EndDate?: string } | undefined
const imageTag = ch.ImageTags?.Primary
const img = ch.Id && imageTag ? getImageUrl(serverUrl, ch.Id, 'Primary', 128, imageTag) : ''
return (
<li key={ch.Id}>
<button
onClick={() => ch.Id && navigate(`/play/${ch.Id}`)}
className="w-full text-left p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition flex items-center gap-3 focus-ring"
>
<div className="w-12 h-12 rounded-md bg-void/50 grid place-items-center shrink-0 overflow-hidden">
{img ? (
<img src={img} alt="" className="w-full h-full object-contain" />
) : (
<Radio size={18} className="text-text-3" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate flex items-baseline gap-2">
{ch.ChannelNumber && (
<span className="text-[11px] text-text-4 tabular-nums shrink-0">{ch.ChannelNumber}</span>
)}
{ch.Name || 'Channel'}
</p>
{program?.Name ? (
<p className="text-[11.5px] text-text-3 truncate mt-0.5">
{programTime(program.StartDate, program.EndDate)}{' '}<span className="text-text-2">{program.Name}</span>
</p>
) : (
<p className="text-[11.5px] text-text-4 italic mt-0.5">No program info</p>
)}
</div>
<Play size={14} className="text-accent shrink-0" />
</button>
</li>
)
})}
</ul>
)
}
function RecordingsList() {
const navigate = useNavigate()
const serverUrl = jellyfinClient.getAuthState()?.serverUrl || ''
const { data: recordings = [], isLoading } = useLiveTvRecordings()
if (isLoading) return <Skeleton />
if (recordings.length === 0) {
return <EmptyMsg>No recordings yet. Schedule one from a program to get started.</EmptyMsg>
}
return (
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
{recordings.map(r => {
const imageTag = r.ImageTags?.Primary
const img = r.Id && imageTag ? getImageUrl(serverUrl, r.Id, 'Primary', 220, imageTag) : ''
return (
<li key={r.Id}>
<button
onClick={() => r.Id && navigate(`/item/${r.Id}`)}
className="w-full text-left p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition flex items-center gap-3 focus-ring"
>
<div className="w-20 h-12 rounded-md bg-void/50 overflow-hidden shrink-0">
{img && <img src={img} alt="" className="w-full h-full object-cover" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate">
{r.Name || 'Untitled'}
</p>
<p className="text-[11.5px] text-text-3 truncate mt-0.5 flex items-center gap-1.5">
<Clock size={11} />
{r.StartDate ? new Date(r.StartDate).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
{r.ChannelName && <span className="text-text-4"> - {r.ChannelName}</span>}
</p>
</div>
</button>
</li>
)
})}
</ul>
)
}
function ScheduledList() {
const { data: timers = [], isLoading } = useLiveTvTimers()
if (isLoading) return <Skeleton />
if (timers.length === 0) {
return <EmptyMsg>Nothing on the schedule.</EmptyMsg>
}
return (
<ul className="space-y-1.5">
{timers.map((t: any) => (
<li
key={t.Id}
className="p-3 rounded-lg bg-elevated/40 ring-1 ring-border flex items-center gap-3"
>
<div className="w-10 h-10 rounded-md bg-accent/10 grid place-items-center shrink-0">
<CalendarEvent size={16} className="text-accent" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate">
{t.Name || t.ProgramName || 'Scheduled recording'}
</p>
<p className="text-[11.5px] text-text-3 truncate mt-0.5">
{t.StartDate ? new Date(t.StartDate).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
{t.ChannelName && <span className="text-text-4"> - {t.ChannelName}</span>}
</p>
</div>
{t.Status && (
<span className="text-[10.5px] uppercase tracking-[0.14em] font-semibold text-text-4">
{t.Status}
</span>
)}
</li>
))}
</ul>
)
}
function programTime(start?: string | null, end?: string | null): string {
if (!start) return ''
const s = new Date(start)
const e = end ? new Date(end) : null
if (Number.isNaN(s.getTime())) return ''
const fmt = (d: Date) => d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
return e ? `${fmt(s)}-${fmt(e)} ` : `${fmt(s)} `
}
function Skeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-[68px] rounded-lg bg-elevated/20 animate-pulse" />
))}
</div>
)
}
function EmptyMsg({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
<p className="text-[12.5px] text-text-3">{children}</p>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Eye, EyeOff, Loader2, AlertCircle, Server, User as UserIcon, Lock, ArrowRight } from '../lib/icons'
import { jellyfinClient, getLastServerUrl } from '../api/jellyfin'
import type { AuthState } from '../api/types'
interface Props {
onLogin: (auth: AuthState) => void
}
export default function LoginPage({ onLogin }: Props) {
const [serverUrl, setServerUrl] = useState(() => getLastServerUrl())
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!serverUrl.trim()) {
setError('Please enter a server URL')
return
}
setLoading(true)
try {
jellyfinClient.connect(serverUrl.trim())
const auth = await jellyfinClient.login(username.trim(), password)
onLogin(auth)
} catch (err: any) {
setError(
err?.response?.data?.message ||
err?.message ||
'Connection failed. Check your server URL and credentials.'
)
} finally {
setLoading(false)
}
}
return (
<div className="absolute inset-0 flex items-center justify-center bg-void overflow-hidden">
{/* Cinematic ambient backdrop */}
<div className="absolute inset-0 -z-10">
<div className="absolute top-[-30%] left-[-20%] w-[80%] h-[80%] rounded-full bg-accent/10 blur-[120px]" />
<div className="absolute bottom-[-30%] right-[-20%] w-[70%] h-[70%] rounded-full bg-cool/8 blur-[120px]" />
<div className="absolute inset-0 noise" />
</div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-[400px] mx-6 relative"
>
{/* Brand */}
<div className="flex flex-col items-center mb-9">
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.34, 1.56, 0.64, 1], delay: 0.1 }}
className="relative w-14 h-14 mb-4"
>
<div className="absolute inset-0 rounded-2xl bg-accent-glow blur-xl" />
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-accent to-accent-press shadow-lg" />
<div className="relative w-full h-full rounded-2xl grid place-items-center text-void font-bold text-2xl font-display">
j
</div>
</motion.div>
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25 }}
className="font-display text-2xl font-semibold text-text-1 tracking-tight"
>
Welcome back
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.32 }}
className="text-[13px] text-text-3 mt-1"
>
Connect to your Jellyfin server to begin
</motion.p>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<Field
label="Server URL"
icon={Server}
type="url"
value={serverUrl}
onChange={setServerUrl}
placeholder="https://jellyfin.example.com"
autoFocus={!serverUrl}
delay={0.4}
/>
<Field
label="Username"
icon={UserIcon}
type="text"
value={username}
onChange={setUsername}
placeholder="Your username"
autoComplete="username"
autoFocus={!!serverUrl}
delay={0.45}
/>
<Field
label="Password"
icon={Lock}
type={showPassword ? 'text' : 'password'}
value={password}
onChange={setPassword}
placeholder="Your password"
autoComplete="current-password"
delay={0.5}
trailing={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-text-4 hover:text-text-2 transition-colors p-1 rounded-md focus-ring"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
}
/>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -4, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -4, height: 0 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="flex items-start gap-2 p-3 rounded-lg bg-error/8 border border-error/20">
<AlertCircle size={14} className="text-error/90 shrink-0 mt-px" />
<p className="text-[12.5px] text-error/95 leading-relaxed">{error}</p>
</div>
</motion.div>
)}
</AnimatePresence>
<motion.button
type="submit"
disabled={loading}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.55 }}
whileTap={{ scale: 0.98 }}
className="group relative w-full h-11 mt-2 bg-accent hover:bg-accent-hover text-void text-[14px] font-semibold rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shadow-lg shadow-accent/20 hover:shadow-accent/40 focus-ring"
>
{loading ? (
<>
<Loader2 size={15} className="animate-[spin-soft_0.8s_linear_infinite]" />
Connecting...
</>
) : (
<>
Sign in
<ArrowRight
size={15}
className="transition-transform duration-200 group-hover:translate-x-0.5"
/>
</>
)}
</motion.button>
</form>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="text-center text-[11px] text-text-4 mt-6"
>
Your server connection stays on this device.
</motion.p>
</motion.div>
</div>
)
}
function Field({
label,
icon: Icon,
type,
value,
onChange,
placeholder,
autoFocus,
autoComplete,
delay = 0,
trailing,
}: {
label: string
icon: typeof Server
type: string
value: string
onChange: (v: string) => void
placeholder?: string
autoFocus?: boolean
autoComplete?: string
delay?: number
trailing?: React.ReactNode
}) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay, ease: [0.16, 1, 0.3, 1] }}
>
<label className="block text-[11px] font-medium text-text-3 mb-1.5 uppercase tracking-wider">
{label}
</label>
<div className="relative group">
<Icon
size={14}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-4 group-focus-within:text-accent transition-colors duration-200"
/>
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
autoComplete={autoComplete}
className={`w-full h-11 pl-10 ${trailing ? 'pr-10' : 'pr-3'} bg-elevated/60 hover:bg-elevated rounded-lg text-[13.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 focus:bg-elevated transition-all duration-200`}
/>
{trailing && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">{trailing}</div>
)}
</div>
</motion.div>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Music, Disc3 } from '../lib/icons'
import { useLibraryItems } from '../hooks/use-jellyfin'
import PosterCard from '../components/ui/PosterCard'
import { getBestImage, getStoredServerUrl } from '../api/jellyfin'
export default function MusicPage() {
const navigate = useNavigate()
const serverUrl = getStoredServerUrl()
const { data: artists, isLoading: artistsLoading } = useLibraryItems(undefined, {
includeItemTypes: ['MusicArtist'],
sortBy: ['SortName'],
sortOrder: ['Ascending'],
limit: 100,
})
const { data: albums, isLoading: albumsLoading } = useLibraryItems(undefined, {
includeItemTypes: ['MusicAlbum'],
sortBy: ['SortName'],
sortOrder: ['Ascending'],
limit: 100,
})
return (
<div className="px-7 pt-4 pb-12">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">Music</h1>
</div>
{/* Albums */}
<Section title="Albums" icon={Disc3}>
{albumsLoading ? (
<SquareGridSkeleton count={14} />
) : !albums?.Items?.length ? (
<EmptyState message="No albums" />
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 gap-y-7">
{albums.Items.map((item, i) => (
<motion.div
key={item.Id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.015, 0.3), ease: [0.16, 1, 0.3, 1] }}
>
<PosterCard
item={item}
aspect="square"
priority={i < 12}
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
/>
</motion.div>
))}
</div>
)}
</Section>
{/* Artists */}
<Section title="Artists" icon={Music}>
{artistsLoading ? (
<ArtistGridSkeleton />
) : !artists?.Items?.length ? (
<EmptyState message="No artists" />
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-x-3 gap-y-5">
{artists.Items.map((item, i) => {
const imageUrl = getBestImage(serverUrl, item, 'primary', 240)
return (
<motion.button
key={item.Id}
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.015, 0.3) }}
whileHover={{ y: -2 }}
className="flex flex-col items-center text-center gap-2 group focus-ring rounded-xl p-1"
>
<div className="relative w-20 h-20 md:w-24 md:h-24">
<div className="absolute inset-0 rounded-full bg-accent/0 group-hover:bg-accent/15 blur-xl transition-all duration-300" />
<div className="relative w-full h-full rounded-full overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-accent/40 transition-all duration-300">
{imageUrl ? (
<img
src={imageUrl}
alt={item.Name || ''}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-3 text-lg font-display font-semibold bg-gradient-to-br from-elevated to-surface">
{(item.Name || '?')[0].toUpperCase()}
</div>
)}
</div>
</div>
<span className="text-[12px] text-text-1 truncate w-full font-medium tracking-tight">{item.Name}</span>
</motion.button>
)
})}
</div>
)}
</Section>
</div>
)
}
function Section({
title,
icon: Icon,
children,
}: {
title: string
icon: typeof Music
children: React.ReactNode
}) {
return (
<section className="mb-10">
<div className="flex items-center gap-2 mb-4">
<Icon size={14} className="text-accent" strokeWidth={2} />
<h2 className="text-[16px] font-semibold text-text-1 tracking-tight">{title}</h2>
</div>
{children}
</section>
)
}
function EmptyState({ message }: { message: string }) {
return <p className="text-[12px] text-text-4">{message}</p>
}
function SquareGridSkeleton({ count }: { count: number }) {
return (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 gap-y-7">
{Array.from({ length: count }).map((_, i) => (
<div key={i}>
<div className="skeleton aspect-square rounded-lg" />
<div className="mt-2 space-y-1">
<div className="skeleton h-3 w-3/4 rounded" />
<div className="skeleton h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</div>
)
}
function ArtistGridSkeleton() {
return (
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-x-3 gap-y-5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="flex flex-col items-center">
<div className="skeleton w-20 h-20 md:w-24 md:h-24 rounded-full" />
<div className="skeleton h-3 w-16 mt-2 rounded" />
</div>
))}
</div>
)
}
+329
View File
@@ -0,0 +1,329 @@
import { useMemo, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Cake, MapPin, Skull, ExternalLink, ChevronDown } from '../lib/icons'
import { useTmdbPerson } from '../hooks/use-tmdb'
import { useWikiResolve } from '../hooks/use-external'
import { getTmdbImageUrl } from '../api/tmdb'
import type { TmdbCombinedCreditCast, TmdbCombinedCreditCrew } from '../api/tmdb'
import { formatDate } from '../lib/format'
import { usePosterGridClasses } from '../lib/density'
const DEPT_FILTERS = ['All', 'Acting', 'Directing', 'Writing', 'Production', 'Camera', 'Editing', 'Sound']
export default function PersonPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: person, isLoading } = useTmdbPerson(id)
const gridCls = usePosterGridClasses()
// Wikipedia bio fallback - only fetched when TMDB has no biography of
// its own. The query is the person's name; first matching article wins.
const wikiEnabled = !!person && (!person.biography || person.biography.length < 60)
const { data: wiki } = useWikiResolve(person?.name, wikiEnabled)
const [bioExpanded, setBioExpanded] = useState(false)
const [filter, setFilter] = useState<string>('All')
const filmography = useMemo(() => {
const cast = (person?.combined_credits?.cast || []).map(c => ({ ...c, _kind: 'cast' as const }))
const crew = (person?.combined_credits?.crew || []).map(c => ({ ...c, _kind: 'crew' as const }))
let merged: ((TmdbCombinedCreditCast | TmdbCombinedCreditCrew) & { _kind: 'cast' | 'crew' })[] = [
...cast,
...crew,
]
if (filter !== 'All') {
if (filter === 'Acting') {
merged = merged.filter(c => c._kind === 'cast')
} else {
merged = merged.filter(c => c._kind === 'crew' && (c as any).department === filter)
}
}
// De-duplicate by media id (a person may appear in cast + crew)
const seen = new Set<string>()
const unique: typeof merged = []
for (const c of merged) {
const key = `${c.media_type}-${c.id}`
if (seen.has(key)) continue
seen.add(key)
unique.push(c)
}
// Sort by popularity, then date
return unique.sort((a, b) => {
const pa = a.popularity ?? 0
const pb = b.popularity ?? 0
if (pb !== pa) return pb - pa
const da = (a.release_date || a.first_air_date || '0').slice(0, 4)
const db = (b.release_date || b.first_air_date || '0').slice(0, 4)
return db.localeCompare(da)
})
}, [person, filter])
if (isLoading) return <PersonSkeleton />
if (!person) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-6">
<p className="text-text-2 text-[15px] mb-1">Person not found</p>
<p className="text-text-4 text-[12px]">No TMDB key configured, or this person isn't in TMDB.</p>
</div>
)
}
const profile = person.profile_path ? getTmdbImageUrl(person.profile_path, 'w500') : ''
const department = person.known_for_department || 'Acting'
return (
<div className="px-7 pt-6 pb-12">
{/* Top row */}
<div className="grid md:grid-cols-[180px_1fr] gap-7 mb-9">
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-[180px] aspect-[2/3] rounded-xl overflow-hidden ring-1 ring-border bg-elevated mx-auto md:mx-0"
>
{profile ? (
<img src={profile} alt={person.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full grid place-items-center text-text-3 text-3xl font-display">
{person.name[0]}
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
>
<p className="text-[10px] uppercase tracking-[0.14em] text-accent font-semibold mb-1">
{department}
</p>
<h1 className="font-display text-3xl md:text-4xl font-bold text-text-1 tracking-tight mb-3">
{person.name}
</h1>
<div className="flex items-center gap-4 flex-wrap text-[12.5px] text-text-3 mb-5">
{person.birthday && (
<span className="inline-flex items-center gap-1.5">
<Cake size={12} />
{formatDate(person.birthday)}
{!person.deathday && (
<span className="text-text-4">({yearsSince(person.birthday)})</span>
)}
</span>
)}
{person.deathday && (
<span className="inline-flex items-center gap-1.5">
<Skull size={12} />
{formatDate(person.deathday)}
</span>
)}
{person.place_of_birth && (
<span className="inline-flex items-center gap-1.5">
<MapPin size={12} />
{person.place_of_birth}
</span>
)}
</div>
{(person.biography || wiki?.extract) && (
<div>
<p
className={`text-[13px] text-text-2 leading-relaxed max-w-[72ch] whitespace-pre-line ${
!bioExpanded ? 'line-clamp-6' : ''
}`}
>
{person.biography || wiki?.extract}
</p>
{(person.biography || wiki?.extract || '').length > 600 && (
<button
onClick={() => setBioExpanded(b => !b)}
className="text-[12px] text-accent hover:text-accent-hover transition-colors font-medium mt-1.5 inline-flex items-center gap-1 focus-ring rounded"
>
{bioExpanded ? 'Show less' : 'Read more'}
<ChevronDown
size={12}
className={`transition-transform duration-200 ${bioExpanded ? 'rotate-180' : ''}`}
/>
</button>
)}
{!person.biography && wiki && (
<p className="text-[10.5px] text-text-4 mt-1.5">Source: Wikipedia</p>
)}
</div>
)}
{/* External links */}
<div className="flex items-center gap-1.5 mt-5 flex-wrap">
{person.imdb_id && (
<ExternalLinkChip href={`https://www.imdb.com/name/${person.imdb_id}/`} label="IMDb" />
)}
<ExternalLinkChip
href={`https://www.themoviedb.org/person/${person.id}`}
label="TMDB"
/>
{person.external_ids?.instagram_id && (
<ExternalLinkChip
href={`https://instagram.com/${person.external_ids.instagram_id}`}
label="Instagram"
/>
)}
{person.external_ids?.twitter_id && (
<ExternalLinkChip
href={`https://twitter.com/${person.external_ids.twitter_id}`}
label="Twitter"
/>
)}
{person.homepage && <ExternalLinkChip href={person.homepage} label="Website" />}
</div>
</motion.div>
</div>
{/* Filmography */}
<div className="mb-3 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<h3 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">
Filmography
</h3>
<span className="text-[11px] text-text-4 tabular-nums">
({filmography.length})
</span>
</div>
<div className="flex items-center gap-1 p-0.5 bg-elevated/60 border border-border rounded-md overflow-x-auto hide-scrollbar">
{DEPT_FILTERS.map(d => {
const isActive = filter === d
return (
<button
key={d}
onClick={() => setFilter(d)}
className={`relative h-7 px-2.5 rounded text-[11.5px] font-medium tracking-tight whitespace-nowrap transition-colors duration-150 focus-ring ${
isActive ? 'text-void' : 'text-text-3 hover:text-text-1'
}`}
>
{isActive && (
<motion.span
layoutId="filmo-active"
className="absolute inset-0 bg-accent rounded"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<span className="relative">{d}</span>
</button>
)
})}
</div>
</div>
{filmography.length === 0 ? (
<p className="text-[13px] text-text-4 mt-4">No credits in this category.</p>
) : (
<div className={gridCls}>
{filmography.map((c, i) => {
const title = c.title || c.name || 'Untitled'
const year = (c.release_date || c.first_air_date || '').slice(0, 4)
return (
<motion.button
key={`${c.media_type}-${c.id}-${c.credit_id}`}
onClick={() => navigate(`/item/tmdb-${c.id}`)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.012, 0.4) }}
whileHover={{ y: -3 }}
className="group text-left focus-ring rounded-lg"
>
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-accent/30 transition-all duration-200">
{c.poster_path ? (
<img
src={getTmdbImageUrl(c.poster_path, 'w300')}
alt={title}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-3 text-2xl font-display">
{title[0]}
</div>
)}
<div className="absolute top-1.5 left-1.5">
<span className="inline-flex items-center h-[18px] px-1.5 rounded text-[9px] font-bold tracking-[0.04em] uppercase bg-black/55 backdrop-blur text-white border border-white/10">
{c.media_type === 'tv' ? 'TV' : 'Film'}
</span>
</div>
</div>
<div className="mt-2 px-px">
<p className="text-[12.5px] font-medium text-text-1 truncate leading-tight tracking-tight">
{title}
</p>
<p className="text-[11px] text-text-3 truncate leading-tight mt-0.5">
{(c as any).character ? (c as any).character : (c as any).job}
{year && (
<>
<span className="text-text-5 mx-1">·</span>
<span className="tabular-nums">{year}</span>
</>
)}
</p>
</div>
</motion.button>
)
})}
</div>
)}
</div>
)
}
function ExternalLinkChip({ href, label }: { href: string; label: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 h-7 px-2.5 bg-elevated/60 hover:bg-elevated border border-border hover:border-border-hover rounded-md text-[11px] text-text-2 hover:text-text-1 transition-colors focus-ring"
>
{label}
<ExternalLink size={10} className="text-text-4" />
</a>
)
}
function yearsSince(iso: string): string {
try {
const d = new Date(iso)
const years = (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000)
return `${Math.floor(years)} yrs`
} catch {
return ''
}
}
function PersonSkeleton() {
const gridCls = usePosterGridClasses()
return (
<div className="px-7 pt-6 pb-12">
<div className="grid md:grid-cols-[180px_1fr] gap-7 mb-9">
<div className="aspect-[2/3] skeleton rounded-xl" />
<div className="space-y-3">
<div className="skeleton h-3 w-24 rounded" />
<div className="skeleton h-9 w-2/3 rounded" />
<div className="skeleton h-3 w-1/2 rounded" />
<div className="skeleton h-20 w-full rounded mt-3" />
</div>
</div>
<div className={gridCls}>
{Array.from({ length: 14 }).map((_, i) => (
<div key={i}>
<div className="skeleton aspect-[2/3] rounded-lg" />
<div className="skeleton h-3 w-3/4 mt-2 rounded" />
<div className="skeleton h-2.5 w-1/2 mt-1 rounded" />
</div>
))}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ListMusic } from '../lib/icons'
import { useLibraryItems } from '../hooks/use-jellyfin'
import PosterCard from '../components/ui/PosterCard'
import { usePosterGridClasses } from '../lib/density'
export default function PlaylistsPage() {
const navigate = useNavigate()
const gridCls = usePosterGridClasses()
const { data, isLoading } = useLibraryItems(undefined, {
includeItemTypes: ['Playlist'],
sortBy: ['SortName'],
sortOrder: ['Ascending'],
limit: 200,
})
const items = data?.Items || []
return (
<div className="px-7 pt-4 pb-12">
<div className="mb-7">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
</div>
<div className="flex items-baseline gap-3">
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Playlists
</h1>
{items.length > 0 && (
<span className="text-[12px] text-text-4 tabular-nums">
{items.length.toLocaleString()} {items.length === 1 ? 'playlist' : 'playlists'}
</span>
)}
</div>
</div>
{isLoading ? (
<SkeletonGrid />
) : items.length === 0 ? (
<EmptyState />
) : (
<div className={gridCls}>
{items.map((item, i) => (
<motion.div
key={item.Id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.012, 0.3), ease: [0.16, 1, 0.3, 1] }}
>
<PosterCard
item={item}
aspect="square"
priority={i < 12}
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
/>
</motion.div>
))}
</div>
)}
</div>
)
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
<div className="relative w-16 h-16 mb-4">
<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">
<ListMusic size={22} className="text-text-3" />
</div>
</div>
<p className="text-[15px] font-medium text-text-1 mb-1.5">No playlists yet</p>
<p className="text-[12px] text-text-4 max-w-sm">
Create a playlist on your Jellyfin server and it'll show up here.
</p>
</div>
)
}
function SkeletonGrid() {
const gridCls = usePosterGridClasses()
return (
<div className={gridCls}>
{Array.from({ length: 12 }).map((_, i) => (
<div key={i}>
<div className="skeleton aspect-square rounded-lg" />
<div className="mt-2 space-y-1">
<div className="skeleton h-3 w-3/4 rounded" />
<div className="skeleton h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</div>
)
}
+343
View File
@@ -0,0 +1,343 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Star, Clock, Flame, Calendar, ChevronRight } from '../lib/icons'
import { useLibraryItems, useItemsByIds } from '../hooks/use-jellyfin'
import { usePersonalData } from '../stores/personal-data-store'
import { useDiary } from '../stores/diary-store'
import { genreBreakdown, watchStreak, totalHoursWatched, longestBinge } from '../lib/watch-stats'
import { jellyfinClient } from '../api/jellyfin'
const CURRENT_YEAR = new Date().getFullYear()
/**
* Personal profile page - the home for the user's own data layer:
* - Watch streak (#46)
* - Genre breakdown (#44)
* - Year-in-Review summary (#43)
* - Recent diary entries (#47, surfaced)
*/
export default function ProfilePage() {
const navigate = useNavigate()
const auth = jellyfinClient.getAuthState()
const { data } = useLibraryItems(undefined, {
includeItemTypes: ['Movie', 'Series', 'Episode'],
sortBy: ['DatePlayed'],
sortOrder: ['Descending'],
filters: ['IsPlayed'],
limit: 1000,
})
const items = useMemo(() => data?.Items || [], [data?.Items])
const yearItems = useMemo(
() =>
items.filter(it => {
const raw = it.UserData?.LastPlayedDate
if (!raw) return false
return new Date(raw).getFullYear() === CURRENT_YEAR
}),
[items],
)
const streak = useMemo(() => watchStreak(items), [items])
const breakdown = useMemo(() => genreBreakdown(items).slice(0, 8), [items])
const breakdownYear = useMemo(() => genreBreakdown(yearItems).slice(0, 6), [yearItems])
const hoursAll = useMemo(() => totalHoursWatched(items), [items])
const hoursYear = useMemo(() => totalHoursWatched(yearItems), [yearItems])
const binge = useMemo(() => longestBinge(yearItems), [yearItems])
const personal = usePersonalData(s => s.entries)
// Top rated by personal rating - independent of played status, so an
// item rated highly that the user hasn't gotten around to opening
// still shows up. Resolve metadata via a separate `useItemsByIds`
// call rather than joining against the played-items list.
const topRatedIds = useMemo(
() =>
Object.entries(personal)
.filter(([, e]) => e.rating >= 8)
.sort((a, b) => b[1].rating - a[1].rating)
.slice(0, 10)
.map(([id]) => id),
[personal],
)
const { data: topRatedItems = [] } = useItemsByIds(topRatedIds)
const topRatedPersonal = useMemo(() => {
return topRatedIds
.map(id => {
const item = topRatedItems.find(it => it.Id === id) || items.find(i => i.Id === id)
const entry = personal[id]
if (!item || !entry) return null
return { id, rating: entry.rating, rewatchCount: entry.rewatchCount, item }
})
.filter((x): x is { id: string; rating: number; rewatchCount: number; item: any } => !!x)
}, [topRatedIds, topRatedItems, personal, items])
const diary = useDiary(s => s.entries)
const recentDiary = useMemo(
() =>
[...diary]
.sort((a, b) => b.watchedAt.localeCompare(a.watchedAt))
.slice(0, 8),
[diary],
)
return (
<div className="px-7 pb-12 pt-6">
<header className="mb-8 flex items-end justify-between gap-6 flex-wrap">
<div>
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
Profile
</p>
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
{auth?.userName ? `${auth.userName}'s year` : 'Your year'}
</h1>
<p className="text-[13px] text-text-3 mt-1.5">
Stats and notes that live only on this device.
</p>
</div>
<button
onClick={() => navigate('/stats')}
className="inline-flex items-center gap-1.5 h-9 px-3.5 rounded-md text-[12.5px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring"
>
See full stats
<ChevronRight size={13} stroke={2} />
</button>
</header>
{/* Stat tiles */}
<div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-3 mb-10">
<StatTile
label="Current streak"
value={String(streak.current)}
unit={streak.current === 1 ? 'day' : 'days'}
accent={streak.current >= 3}
icon={<Flame size={14} className="text-accent" />}
hint={streak.longest > streak.current ? `Best: ${streak.longest}` : 'Keep going'}
/>
<StatTile
label={`Watched in ${CURRENT_YEAR}`}
value={String(yearItems.length)}
unit={yearItems.length === 1 ? 'item' : 'items'}
icon={<Calendar size={14} className="text-accent" />}
/>
<StatTile
label="Hours this year"
value={hoursYear.toFixed(0)}
unit="hours"
icon={<Clock size={14} className="text-accent" />}
hint={`${hoursAll.toFixed(0)} all-time`}
/>
<StatTile
label="Longest binge"
value={String(binge.count)}
unit={binge.count === 1 ? 'item' : 'items'}
icon={<Flame size={14} className="text-accent" />}
hint={binge.day || 'No binge yet'}
/>
</div>
{/* Genre breakdown */}
<section className="mb-10">
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight">
Genre breakdown
</h2>
{breakdown.length === 0 ? (
<p className="text-[12.5px] text-text-3">No genre data yet.</p>
) : (
<div className="space-y-2">
{breakdown.map((g, i) => (
<GenreBar key={g.genre} share={g} index={i} />
))}
</div>
)}
{breakdownYear.length > 0 && (
<p className="mt-4 text-[12px] text-text-3 leading-relaxed">
Top this year:{' '}
{breakdownYear.map((g, i) => (
<span key={g.genre}>
<span className="text-text-2 font-medium">{g.genre}</span>
<span className="text-text-4 tabular-nums"> {(g.share * 100).toFixed(0)}%</span>
{i < breakdownYear.length - 1 && <span className="text-text-5"> · </span>}
</span>
))}
</p>
)}
</section>
{/* Year in Review - top personal ratings */}
<section className="mb-10">
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight">
Your top picks
</h2>
{topRatedPersonal.length === 0 ? (
<p className="text-[12.5px] text-text-3">
No 8+/10 personal ratings yet. Use the rating row on a detail page to mark your favourites.
</p>
) : (
<ol className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-2">
{topRatedPersonal.map((entry, i) => (
<TopPickRow key={entry.id} index={i + 1} entry={entry} />
))}
</ol>
)}
</section>
{/* Recent diary */}
{recentDiary.length > 0 && (
<section className="mb-10">
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight">
Recent diary
</h2>
<ul className="space-y-2">
{recentDiary.map(d => (
<li
key={d.id}
className="rounded-lg bg-elevated/40 ring-1 ring-border p-3 flex items-start gap-3"
>
{d.emoji && <span className="text-[20px] leading-none">{d.emoji}</span>}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap text-[11.5px] text-text-3 mb-1 tracking-tight">
<span className="text-text-2 font-medium tabular-nums">
{new Date(d.watchedAt).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
})}
</span>
<span className="text-text-5">·</span>
<NavLink itemId={d.itemId} name={d.itemName} />
{d.rating != null && d.rating > 0 && (
<>
<span className="text-text-5">·</span>
<span className="inline-flex items-center gap-0.5 text-accent tabular-nums">
<Star size={10} fill="currentColor" stroke={0} />
{d.rating}/10
</span>
</>
)}
</div>
{d.note && (
<p className="text-[12.5px] text-text-1 leading-relaxed line-clamp-3">
{d.note}
</p>
)}
</div>
</li>
))}
</ul>
</section>
)}
</div>
)
}
function StatTile({
label,
value,
unit,
hint,
icon,
accent,
}: {
label: string
value: string
unit?: string
hint?: string
icon?: React.ReactNode
accent?: boolean
}) {
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className={`rounded-xl p-4 ring-1 ${
accent ? 'bg-accent/10 ring-accent/30' : 'bg-elevated/40 ring-border'
}`}
>
<div className="flex items-center gap-2 text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
{icon}
{label}
</div>
<div className="flex items-baseline gap-1.5">
<span className="font-display text-[28px] font-bold text-text-1 tabular-nums leading-none">
{value}
</span>
{unit && <span className="text-[12px] text-text-3 tracking-tight">{unit}</span>}
</div>
{hint && <p className="text-[11px] text-text-4 mt-1.5 tracking-tight">{hint}</p>}
</motion.div>
)
}
function GenreBar({ share, index }: { share: { genre: string; count: number; share: number }; index: number }) {
const pct = Math.max(2, Math.round(share.share * 100))
return (
<motion.div
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: Math.min(index * 0.04, 0.4) }}
className="flex items-center gap-3 text-[12px]"
>
<span className="w-32 shrink-0 text-text-2 font-medium tracking-tight truncate">
{share.genre}
</span>
<div className="flex-1 h-2.5 rounded-full bg-elevated/60 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="h-full bg-accent"
/>
</div>
<span className="w-16 shrink-0 text-right text-text-3 tabular-nums">
{(share.share * 100).toFixed(0)}%
</span>
</motion.div>
)
}
function TopPickRow({
index,
entry,
}: {
index: number
entry: { id: string; rating: number; rewatchCount: number; item: any }
}) {
const navigate = useNavigate()
return (
<li>
<button
onClick={() => navigate(`/item/${entry.id}`)}
className="w-full flex items-center gap-3 p-2.5 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition text-left focus-ring"
>
<span className="w-7 h-7 grid place-items-center rounded-full bg-accent/15 text-accent text-[11px] font-bold tabular-nums shrink-0">
{index}
</span>
<div className="min-w-0 flex-1">
<p className="text-[13px] text-text-1 font-medium truncate tracking-tight">
{entry.item.Name}
</p>
<p className="text-[11px] text-text-3 truncate tabular-nums">
{entry.item.ProductionYear}
{entry.rewatchCount > 0 && ` · ${entry.rewatchCount} rewatch${entry.rewatchCount === 1 ? '' : 'es'}`}
</p>
</div>
<span className="inline-flex items-center gap-1 text-accent tabular-nums shrink-0">
<Star size={11} fill="currentColor" stroke={0} />
{entry.rating}/10
</span>
</button>
</li>
)
}
function NavLink({ itemId, name }: { itemId: string; name: string }) {
const navigate = useNavigate()
return (
<button
onClick={() => navigate(`/item/${itemId}`)}
className="text-text-2 hover:text-accent transition focus-ring rounded font-medium truncate max-w-[14rem]"
>
{name}
</button>
)
}
+391
View File
@@ -0,0 +1,391 @@
import { useMemo } from 'react'
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { Database, RefreshCw, Trash2, Clock, Check, AlertCircle, Settings } from '../lib/icons'
import { useArrInstances } from '../stores/arr-instances-store'
import { useRadarrLibrary, useSonarrLibrary, useRadarrQueue, useSonarrQueue } from '../hooks/use-arr'
import { radarrClient, type RadarrMovie, type RadarrQueueItem } from '../api/radarr'
import { sonarrClient, type SonarrSeries, type SonarrQueueItem } from '../api/sonarr'
/**
* Aggregated requests view: every movie + show currently sitting in
* Sonarr/Radarr but not fully available yet, with per-item actions.
*
* Data sources:
* - Radarr/Sonarr libraries (filter to monitored + missing/partial)
* - Their queues (in-flight downloads with progress)
*
* Hides itself with a setup prompt when no *arr instance is configured.
*/
type Tier = 'default' | '4k'
interface AggregatedRequest {
key: string
kind: 'movie' | 'tv'
tier: Tier
arrId: number
title: string
year?: number | null
poster?: string | null
/** Render label like "Downloading 64%" or "Pending release". */
state: 'processing' | 'pending' | 'partial' | 'requested'
detail?: string
/** Optional progress 0..100 when processing. */
progress?: number
}
export default function RequestsPage() {
const navigate = useNavigate()
const radarr = useArrInstances(s => s.pick('radarr', 'default'))
const radarr4k = useArrInstances(s => s.pick('radarr', '4k'))
const sonarr = useArrInstances(s => s.pick('sonarr', 'default'))
const sonarr4k = useArrInstances(s => s.pick('sonarr', '4k'))
const radarrA = useRadarrLibrary('default')
const radarrB = useRadarrLibrary('4k')
const sonarrA = useSonarrLibrary('default')
const sonarrB = useSonarrLibrary('4k')
const radarrQA = useRadarrQueue('default')
const radarrQB = useRadarrQueue('4k')
const sonarrQA = useSonarrQueue('default')
const sonarrQB = useSonarrQueue('4k')
const aggregated: AggregatedRequest[] = useMemo(() => {
const out: AggregatedRequest[] = []
function pushMovies(list: RadarrMovie[] | null | undefined, queue: { records?: RadarrQueueItem[] } | null | undefined, tier: Tier) {
if (!list) return
const queueByMovieId = new Map<number, RadarrQueueItem>()
for (const q of queue?.records || []) if (q.movieId != null) queueByMovieId.set(q.movieId, q)
for (const m of list) {
if (m.hasFile) continue
if (!m.id) continue
const q = queueByMovieId.get(m.id)
const poster = m.images?.find(x => x.coverType === 'poster')?.remoteUrl || null
if (q) {
const total = (q.size ?? 0)
const left = (q.sizeleft ?? 0)
const progress = total > 0 ? Math.round(((total - left) / total) * 100) : undefined
out.push({
key: `radarr-${tier}-${m.id}`,
kind: 'movie',
tier,
arrId: m.id,
title: m.title,
year: m.year,
poster,
state: 'processing',
detail: q.timeleft ? `${progress ?? 0}% · ${q.timeleft} left` : `${progress ?? 0}%`,
progress,
})
} else if (m.monitored) {
out.push({
key: `radarr-${tier}-${m.id}`,
kind: 'movie',
tier,
arrId: m.id,
title: m.title,
year: m.year,
poster,
state: 'pending',
detail: m.status === 'announced' ? 'Announced' : m.status === 'inCinemas' ? 'In cinemas' : 'Searching for release',
})
} else {
out.push({
key: `radarr-${tier}-${m.id}`,
kind: 'movie',
tier,
arrId: m.id,
title: m.title,
year: m.year,
poster,
state: 'requested',
detail: 'Not monitored',
})
}
}
}
function pushSeries(list: SonarrSeries[] | null | undefined, queue: { records?: SonarrQueueItem[] } | null | undefined, tier: Tier) {
if (!list) return
const queueBySeriesId = new Map<number, SonarrQueueItem[]>()
for (const q of queue?.records || []) {
if (q.seriesId == null) continue
const existing = queueBySeriesId.get(q.seriesId) || []
queueBySeriesId.set(q.seriesId, [...existing, q])
}
for (const s of list) {
if (!s.id) continue
const seasons = s.seasons || []
const totalEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeCount || 0), 0)
const haveEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeFileCount || 0), 0)
if (totalEps > 0 && haveEps >= totalEps) continue
const poster = s.images?.find(x => x.coverType === 'poster')?.remoteUrl || null
const q = queueBySeriesId.get(s.id)
const monitoredSeasons = seasons.filter(x => x.monitored).length
if (q && q.length > 0) {
out.push({
key: `sonarr-${tier}-${s.id}`,
kind: 'tv',
tier,
arrId: s.id,
title: s.title,
year: s.year,
poster,
state: 'processing',
detail: `${q.length} episode${q.length === 1 ? '' : 's'} downloading`,
})
} else if (haveEps > 0 && haveEps < totalEps) {
out.push({
key: `sonarr-${tier}-${s.id}`,
kind: 'tv',
tier,
arrId: s.id,
title: s.title,
year: s.year,
poster,
state: 'partial',
detail: `${haveEps} of ${totalEps} episodes`,
progress: totalEps > 0 ? Math.round((haveEps / totalEps) * 100) : undefined,
})
} else if (monitoredSeasons > 0) {
out.push({
key: `sonarr-${tier}-${s.id}`,
kind: 'tv',
tier,
arrId: s.id,
title: s.title,
year: s.year,
poster,
state: 'pending',
detail: `${monitoredSeasons} season${monitoredSeasons === 1 ? '' : 's'} monitored`,
})
} else {
out.push({
key: `sonarr-${tier}-${s.id}`,
kind: 'tv',
tier,
arrId: s.id,
title: s.title,
year: s.year,
poster,
state: 'requested',
detail: 'No seasons monitored',
})
}
}
}
pushMovies(radarrA.data, radarrQA.data, 'default')
pushMovies(radarrB.data, radarrQB.data, '4k')
pushSeries(sonarrA.data, sonarrQA.data, 'default')
pushSeries(sonarrB.data, sonarrQB.data, '4k')
// Sort: processing first, then partial, pending, requested. Within
// each bucket, alphabetical.
const order: Record<AggregatedRequest['state'], number> = { processing: 0, partial: 1, pending: 2, requested: 3 }
return out.sort((a, b) => {
const oa = order[a.state]
const ob = order[b.state]
if (oa !== ob) return oa - ob
return a.title.localeCompare(b.title)
})
}, [radarrA.data, radarrB.data, sonarrA.data, sonarrB.data, radarrQA.data, radarrQB.data, sonarrQA.data, sonarrQB.data])
const noInstance = !radarr && !radarr4k && !sonarr && !sonarr4k
const counts = useMemo(() => {
const out = { processing: 0, partial: 0, pending: 0, requested: 0 }
for (const r of aggregated) out[r.state]++
return out
}, [aggregated])
return (
<div className="px-7 pt-4 pb-12">
<header className="mb-7 flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Requests</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Pending downloads
</h1>
<p className="text-[13px] text-text-3 mt-1.5">
Items currently in your *arr stack that haven't finished landing in Jellyfin.
</p>
</div>
{!noInstance && aggregated.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{counts.processing > 0 && <CountChip tone="blue" label="Downloading" value={counts.processing} />}
{counts.partial > 0 && <CountChip tone="amber" label="Partial" value={counts.partial} />}
{counts.pending > 0 && <CountChip tone="purple" label="Pending" value={counts.pending} />}
{counts.requested > 0 && <CountChip tone="neutral" label="Requested" value={counts.requested} />}
</div>
)}
</header>
{noInstance ? (
<div className="rounded-xl bg-elevated/30 ring-1 ring-border p-10 text-center">
<div className="relative w-14 h-14 mx-auto mb-4">
<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">
<Database size={20} className="text-accent" />
</div>
</div>
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1.5">No Sonarr or Radarr connected</p>
<p className="text-[12.5px] text-text-3 max-w-md mx-auto leading-relaxed mb-5">
Connect a Sonarr or Radarr instance to track active downloads, monitor pending releases, and manage requests here.
</p>
<button
onClick={() => navigate('/settings#sonarr-radarr')}
className="inline-flex items-center gap-1.5 h-10 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[12.5px] font-semibold tracking-tight transition shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)] focus-ring"
>
<Settings size={12} stroke={2} />
Open Settings
</button>
</div>
) : aggregated.length === 0 ? (
<div className="rounded-xl bg-elevated/30 ring-1 ring-border p-10 text-center">
<div className="relative w-14 h-14 mx-auto mb-4">
<div className="absolute inset-0 rounded-full bg-success/10 blur-xl" />
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-success/30 grid place-items-center">
<Check size={18} className="text-success" stroke={2.5} />
</div>
</div>
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">All caught up</p>
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
Every monitored item in your *arr stack is fully downloaded.
</p>
</div>
) : (
<ul className="space-y-2">
{aggregated.map((req, i) => (
<RequestRow key={req.key} req={req} index={i} />
))}
</ul>
)}
</div>
)
}
function RequestRow({ req, index }: { req: AggregatedRequest; index: number }) {
const qc = useQueryClient()
const radarrInstance = useArrInstances(s => s.pick('radarr', req.tier))
const sonarrInstance = useArrInstances(s => s.pick('sonarr', req.tier))
async function searchNow() {
if (req.kind === 'movie' && radarrInstance) {
await radarrClient(radarrInstance).searchMovie(req.arrId)
qc.invalidateQueries({ queryKey: ['radarr', 'queue'] })
} else if (req.kind === 'tv' && sonarrInstance) {
await sonarrClient(sonarrInstance).searchSeries(req.arrId)
qc.invalidateQueries({ queryKey: ['sonarr', 'queue'] })
}
}
async function cancel() {
const ok = confirm(`Remove "${req.title}" from ${req.kind === 'movie' ? 'Radarr' : 'Sonarr'}? Files on disk will be left in place.`)
if (!ok) return
if (req.kind === 'movie' && radarrInstance) {
await radarrClient(radarrInstance).removeMovie(req.arrId, false)
qc.invalidateQueries({ queryKey: ['radarr', 'library'] })
} else if (req.kind === 'tv' && sonarrInstance) {
await sonarrClient(sonarrInstance).removeSeries(req.arrId, false)
qc.invalidateQueries({ queryKey: ['sonarr', 'library'] })
}
}
return (
<motion.li
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: Math.min(index * 0.02, 0.3) }}
className="flex items-center gap-3 p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong transition"
>
<div className="shrink-0 w-12 aspect-[2/3] rounded bg-black overflow-hidden ring-1 ring-border">
{req.poster && (
<img src={req.poster} alt="" className="w-full h-full object-cover" loading="lazy" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<p className="text-[13px] font-medium text-text-1 truncate tracking-tight">{req.title}</p>
{req.year && <span className="text-[11px] text-text-4 tabular-nums">{req.year}</span>}
<StateChip state={req.state} />
{req.tier === '4k' && (
<span className="inline-flex items-center h-[16px] px-1.5 rounded text-[9px] font-bold uppercase tracking-[0.06em] bg-cool/20 text-cool ring-1 ring-cool/30">
4K
</span>
)}
</div>
{req.detail && (
<p className="text-[11.5px] text-text-3">{req.detail}</p>
)}
{req.progress != null && req.progress >= 0 && req.progress <= 100 && (
<div className="mt-1.5 h-1 rounded-full bg-elevated overflow-hidden max-w-md">
<div
className="h-full bg-accent transition-[width] duration-300"
style={{ width: `${req.progress}%` }}
/>
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
<button
onClick={searchNow}
title="Search now"
aria-label="Search now"
className="w-9 h-9 grid place-items-center rounded-full text-text-3 hover:text-accent hover:bg-elevated transition focus-ring"
>
<RefreshCw size={13} stroke={2} />
</button>
<button
onClick={cancel}
title="Remove from *arr"
aria-label="Remove"
className="w-9 h-9 grid place-items-center rounded-full text-text-3 hover:text-red-300 hover:bg-elevated transition focus-ring"
>
<Trash2 size={13} stroke={2} />
</button>
</div>
</motion.li>
)
}
function CountChip({ tone, label, value }: { tone: 'blue' | 'amber' | 'purple' | 'neutral'; label: string; value: number }) {
const palette =
tone === 'blue' ? 'bg-blue-500/12 text-blue-200 ring-blue-400/30'
: tone === 'amber' ? 'bg-amber-500/12 text-amber-200 ring-amber-400/30'
: tone === 'purple' ? 'bg-purple-500/12 text-purple-200 ring-purple-400/30'
: 'bg-elevated/60 text-text-2 ring-border'
return (
<span className={`inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full ring-1 text-[11.5px] tracking-tight ${palette}`}>
<span className="font-semibold tabular-nums">{value}</span>
<span className="opacity-80">{label}</span>
</span>
)
}
function StateChip({ state }: { state: AggregatedRequest['state'] }) {
const Icon =
state === 'processing' ? RefreshCw
: state === 'partial' ? Check
: state === 'pending' ? Clock
: AlertCircle
const tone =
state === 'processing' ? 'bg-blue-500/20 text-blue-200 ring-blue-400/30'
: state === 'partial' ? 'bg-amber-500/20 text-amber-200 ring-amber-400/30'
: state === 'pending' ? 'bg-purple-500/20 text-purple-200 ring-purple-400/30'
: 'bg-elevated/80 text-text-2 ring-border'
const label =
state === 'processing' ? 'Downloading'
: state === 'partial' ? 'Partial'
: state === 'pending' ? 'Pending'
: 'Requested'
return (
<span className={`inline-flex items-center gap-1 h-[16px] px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] ring-1 ${tone}`}>
<Icon size={9} stroke={2.5} />
{label}
</span>
)
}
+237
View File
@@ -0,0 +1,237 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, X, Loader2 } from '../lib/icons'
import { useLibraryByTmdbId } from '../hooks/use-jellyfin'
import { useFuzzyLibrarySearch } from '../hooks/use-fuzzy-search'
import { useTmdbSearch } from '../hooks/use-tmdb'
import { mapTmdbToJf } from '../lib/tmdb-mapping'
import type { BaseItemDto } from '../api/types'
import { PeopleSection, MoviesShowsSection, EpisodesSection, MusicSection } from './search/sections'
import { EmptyState, LoadingState, NoResults } from './search/empty-states'
const RECENT_KEY = 'jf_recent_searches'
const RECENT_MAX = 8
function readRecent(): string[] {
if (typeof window === 'undefined') return []
try {
const raw = localStorage.getItem(RECENT_KEY)
if (!raw) return []
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr.filter(s => typeof s === 'string').slice(0, RECENT_MAX) : []
} catch {
return []
}
}
function pushRecent(q: string) {
const trimmed = q.trim()
if (!trimmed) return
try {
const cur = readRecent()
const next = [trimmed, ...cur.filter(s => s.toLowerCase() !== trimmed.toLowerCase())].slice(0, RECENT_MAX)
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
} catch { /* noop */ }
}
function clearRecent() {
try { localStorage.removeItem(RECENT_KEY) } catch { /* noop */ }
}
export default function SearchPage() {
const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState('')
const [recents, setRecents] = useState<string[]>(() => readRecent())
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const fuzzy = useFuzzyLibrarySearch(query)
const tmdb = useTmdbSearch(debounced)
const libraryByTmdb = useLibraryByTmdbId()
const jfLoading = fuzzy.isLoading
const jfFetching = fuzzy.isFetching
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
const t = setTimeout(() => setDebounced(query), 220)
return () => clearTimeout(t)
}, [query])
// Persist successful searches once results land. Avoids polluting the
// recents list with mid-typing fragments by waiting for a stable query.
useEffect(() => {
if (!debounced) return
if (debounced.length < 2) return
if (jfLoading || jfFetching) return
pushRecent(debounced)
setRecents(readRecent())
}, [debounced, jfLoading, jfFetching])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (query) setQuery('')
else navigate(-1)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [navigate, query])
const isSearching = !!query && (jfLoading || tmdb.isLoading) && !fuzzy.catalogReady
const hasQuery = !!query.trim()
const grouped = useMemo(() => {
const out: Record<string, BaseItemDto[]> = {}
for (const it of fuzzy.catalogResults) {
const t = (it.Type || 'Other') as string
if (!out[t]) out[t] = []
out[t].push(it)
}
for (const it of fuzzy.episodesAndTracks) {
const t = (it.Type || 'Other') as string
if (!out[t]) out[t] = []
out[t].push(it)
}
return out
}, [fuzzy.catalogResults, fuzzy.episodesAndTracks])
const tmdbResults = tmdb.data?.results || []
const tmdbPeople = tmdbResults.filter(r => r.media_type === 'person').slice(0, 8)
const tmdbMovies = tmdbResults.filter(r => r.media_type === 'movie')
const tmdbTv = tmdbResults.filter(r => r.media_type === 'tv')
const tmdbMappedMovies = useMemo(
() => mapTmdbToJf(tmdbMovies, libraryByTmdb.data),
[tmdbMovies, libraryByTmdb.data],
)
const tmdbMappedTv = useMemo(
() => mapTmdbToJf(tmdbTv, libraryByTmdb.data),
[tmdbTv, libraryByTmdb.data],
)
// Local items first (full BaseItemDto with ImageTags + ProviderIds).
// TMDB extras filtered against local TMDB ids so library hits don't
// duplicate. Anything in the library overrides its TMDB twin.
const localMovies = (grouped.Movie || []) as BaseItemDto[]
const localSeries = (grouped.Series || []) as BaseItemDto[]
const localTmdbIds = new Set<string>()
for (const it of [...localMovies, ...localSeries]) {
const t = (it as any).ProviderIds?.Tmdb
if (t) localTmdbIds.add(String(t))
}
const tmdbExtras = [...tmdbMappedMovies, ...tmdbMappedTv].filter(it => {
const t = (it as any).ProviderIds?.Tmdb
return !!t && !localTmdbIds.has(String(t))
})
const localCards: BaseItemDto[] = [...localMovies, ...localSeries].map(
it => ({ ...(it as any), _inLibrary: true } as BaseItemDto),
)
const mixedCards: BaseItemDto[] = [...localCards, ...tmdbExtras]
const episodes = (grouped.Episode || []) as BaseItemDto[]
const albums = (grouped.MusicAlbum || []) as BaseItemDto[]
const artists = (grouped.MusicArtist || []) as BaseItemDto[]
const tracks = (grouped.Audio || []) as BaseItemDto[]
const totalLocal =
localMovies.length + localSeries.length + episodes.length + albums.length + artists.length + tracks.length
const noResults =
hasQuery &&
!isSearching &&
totalLocal === 0 &&
tmdbPeople.length === 0 &&
tmdbExtras.length === 0
return (
<div className="min-h-full" style={{ isolation: 'isolate' }}>
{/* Sticky search input - fully opaque + isolation: isolate + a high
z so cards (which may have transform/perspective stacking) cannot
render on top during scroll. */}
<div
className="sticky top-0 border-b border-border px-7 pt-5 pb-4"
style={{ zIndex: 50, backgroundColor: 'var(--color-void)' }}
>
<div className="relative max-w-2xl mx-auto">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-3" strokeWidth={2} />
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search movies, shows, episodes, people, music..."
className="w-full h-12 pl-12 pr-12 bg-elevated/60 hover:bg-elevated rounded-xl text-[15px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/60 focus:ring-2 focus:ring-accent/20 focus:bg-elevated transition-all duration-200"
aria-label="Search"
/>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
{isSearching && (
<Loader2 size={14} className="text-text-3 animate-[spin-soft_1s_linear_infinite]" />
)}
{query && !isSearching && (
<button
onClick={() => { setQuery(''); inputRef.current?.focus() }}
className="w-7 h-7 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors focus-ring"
aria-label="Clear search"
>
<X size={14} />
</button>
)}
<kbd className="hidden sm:inline-flex items-center px-1.5 h-5 rounded text-[10px] font-mono text-text-4 bg-void/60 border border-border">
esc
</kbd>
</div>
</div>
</div>
{/* Body */}
<div className="px-7 py-8 max-w-[1600px] mx-auto">
<AnimatePresence mode="wait">
{!hasQuery && (
<EmptyState
key="empty"
recents={recents}
onPick={s => { setQuery(s); inputRef.current?.focus() }}
onClearRecents={() => { clearRecent(); setRecents([]) }}
/>
)}
{hasQuery && isSearching && <LoadingState key="loading" />}
{hasQuery && noResults && <NoResults key="noresults" query={query} />}
{hasQuery && !isSearching && !noResults && (
<motion.div
key="results"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
className="space-y-12"
>
{tmdbPeople.length > 0 && <PeopleSection people={tmdbPeople} />}
{mixedCards.length > 0 && (
<MoviesShowsSection
cards={mixedCards}
localCount={localCards.length}
tmdbExtraCount={tmdbExtras.length}
/>
)}
{episodes.length > 0 && <EpisodesSection items={episodes} />}
{(albums.length > 0 || artists.length > 0 || tracks.length > 0) && (
<MusicSection albums={albums} artists={artists} tracks={tracks} />
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useLayoutEffect, useMemo, useState, type ComponentType } from 'react'
import { motion } from 'framer-motion'
import {
Server,
Play as PlayIcon,
Key,
Info,
Globe,
Lock,
Palette,
Volume2,
Database,
Home,
ListDetails,
Tv,
User,
Search,
Hash,
Activity,
X,
} from '../lib/icons'
import { getStoredAuth } from '../api/jellyfin'
import { usePreferencesStore } from '../stores/preferences-store'
import { SettingsSearchContext, type SettingsSearchValue } from './settings/_ui'
import { ServerSection } from './settings/sections/Server'
import { ServersSection } from './settings/sections/Servers'
import { ServerDashboardSection } from './settings/sections/ServerDashboard'
import { PlaybackSection } from './settings/sections/Playback'
import { AudioSection } from './settings/sections/Audio'
import { DisplaySection } from './settings/sections/Display'
import { HomePageSection } from './settings/sections/Home'
import { DetailPageSection } from './settings/sections/Detail'
import { EpisodesSection } from './settings/sections/Episodes'
import { DiscoverySection } from './settings/sections/Discovery'
import { TmdbSection } from './settings/sections/Tmdb'
import { FanartSection } from './settings/sections/Fanart'
import { TraktSection } from './settings/sections/Trakt'
import { ArrSection } from './settings/sections/Arr'
import { PersonalSettingsSection } from './settings/sections/Personal'
import { PrivacySection } from './settings/sections/Privacy'
import { AboutSection } from './settings/sections/About'
import { ShortcutsSection } from './settings/sections/Shortcuts'
/* ──────────────────────────────────────────────────────────── */
/* Categories - drive the left-rail nav and section anchors */
/* ──────────────────────────────────────────────────────────── */
interface Category {
id: string
icon: ComponentType<{ size?: number; stroke?: number; className?: string }>
label: string
}
interface CategoryGroup {
label: string
items: Category[]
}
const CATEGORY_GROUPS: CategoryGroup[] = [
{
label: 'Account',
items: [
{ id: 'server', icon: Server, label: 'Server' },
{ id: 'servers', icon: Server, label: 'All servers' },
{ id: 'server-dashboard', icon: Activity, label: 'Dashboard' },
],
},
{
label: 'Playback',
items: [
{ id: 'playback', icon: PlayIcon, label: 'Playback' },
{ id: 'audio', icon: Volume2, label: 'Audio & Subtitles' },
{ id: 'shortcuts', icon: Hash, label: 'Shortcuts' },
],
},
{
label: 'Appearance',
items: [
{ id: 'display', icon: Palette, label: 'Display' },
{ id: 'home-page', icon: Home, label: 'Home page' },
{ id: 'detail-page', icon: ListDetails, label: 'Detail page' },
{ id: 'episodes', icon: Tv, label: 'Episodes' },
],
},
{
label: 'Discovery',
items: [
{ id: 'discovery', icon: Globe, label: 'Region & content' },
{ id: 'tmdb', icon: Key, label: 'TMDB' },
{ id: 'fanart', icon: Palette, label: 'Fanart.tv' },
{ id: 'trakt', icon: Activity, label: 'Trakt.tv' },
],
},
{
label: 'Requests',
items: [
{ id: 'sonarr-radarr', icon: Database, label: 'Sonarr & Radarr' },
],
},
{
label: 'Data',
items: [
{ id: 'personal', icon: User, label: 'Your data' },
{ id: 'privacy', icon: Lock, label: 'Privacy' },
],
},
{
label: 'About',
items: [
{ id: 'about', icon: Info, label: 'About' },
],
},
]
const CATEGORIES: Category[] = CATEGORY_GROUPS.flatMap(g => g.items)
export default function SettingsPage() {
const prefs = usePreferencesStore()
const auth = getStoredAuth()
const serverHost = (() => {
try { return auth?.serverUrl ? new URL(auth.serverUrl).host : '-' } catch { return '-' }
})()
const [active, setActive] = useState<string>('server')
const [query, setQuery] = useState('')
const trimmed = query.trim().toLowerCase()
const search = useMemo<SettingsSearchValue>(
() => ({
query: trimmed,
matches: (haystack: string) => {
if (!trimmed) return true
return haystack.toLowerCase().includes(trimmed)
},
}),
[trimmed],
)
// Track the section closest to the top of the scroll viewport so the
// nav highlights what the user is currently reading.
useEffect(() => {
const root = document.querySelector('main.content-scroll') as HTMLElement | null
if (!root) return
const obs = new IntersectionObserver(
entries => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visible.length > 0) setActive(visible[0].target.id)
},
{
root,
rootMargin: '-15% 0px -65% 0px',
threshold: 0,
},
)
CATEGORIES.forEach(c => {
const el = document.getElementById(c.id)
if (el) obs.observe(el)
})
return () => obs.disconnect()
}, [])
function scrollTo(id: string) {
const el = document.getElementById(id)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<div className="px-7 pt-4 pb-16">
<div className="mb-9 max-w-3xl">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Preferences</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">Settings</h1>
<p className="text-[13px] text-text-3 mt-1.5">Customize your Jellyfin experience</p>
</div>
<SettingsSearchContext.Provider value={search}>
<div className="flex gap-10 items-start">
<SectionNav
active={active}
onSelect={scrollTo}
query={query}
onQueryChange={setQuery}
/>
<div className="flex-1 min-w-0 max-w-2xl space-y-12">
<ServerSection serverHost={serverHost} userName={auth?.userName} />
<ServersSection />
<ServerDashboardSection />
<PlaybackSection prefs={prefs} />
<AudioSection prefs={prefs} />
<ShortcutsSection />
<DisplaySection prefs={prefs} />
<HomePageSection prefs={prefs} />
<DetailPageSection prefs={prefs} />
<EpisodesSection prefs={prefs} />
<DiscoverySection prefs={prefs} />
<TmdbSection prefs={prefs} />
<FanartSection prefs={prefs} />
<TraktSection />
<ArrSection />
<PersonalSettingsSection />
<PrivacySection />
<AboutSection />
<SettingsEmptyState query={trimmed} />
</div>
</div>
</SettingsSearchContext.Provider>
</div>
)
}
/**
* "No results" message shown when the current search query hides every
* row on the page. Uses an effect to count visible rows via DOM rather
* than threading state through every row.
*/
function SettingsEmptyState({ query }: { query: string }) {
const [empty, setEmpty] = useState(false)
useLayoutEffect(() => {
if (!query) {
setEmpty(false)
return
}
const visible = document.querySelectorAll('[data-settings-row]').length
setEmpty(visible === 0)
}, [query])
if (!query || !empty) return null
return (
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
<p className="text-[13px] text-text-2 font-medium mb-1.5">No settings match "{query}"</p>
<p className="text-[11.5px] text-text-4">Try a different keyword, or clear the search to browse everything.</p>
</div>
)
}
function SectionNav({
active,
onSelect,
query,
onQueryChange,
}: {
active: string
onSelect: (id: string) => void
query: string
onQueryChange: (next: string) => void
}) {
return (
<nav className="hidden md:flex flex-col gap-4 w-[224px] shrink-0 sticky top-4">
<div className="relative">
<Search
size={13}
stroke={2}
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4 pointer-events-none"
/>
<input
type="search"
value={query}
onChange={e => onQueryChange(e.target.value)}
placeholder="Search settings..."
className="w-full h-9 pl-9 pr-9 rounded-md bg-elevated/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight text-text-1 placeholder:text-text-4 transition"
/>
{query && (
<button
type="button"
onClick={() => onQueryChange('')}
aria-label="Clear search"
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 grid place-items-center rounded-full text-text-4 hover:text-text-1 hover:bg-elevated transition"
>
<X size={11} stroke={2} />
</button>
)}
</div>
<ul className="flex flex-col gap-4">
{CATEGORY_GROUPS.map(group => (
<li key={group.label}>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-4 mb-1.5 ml-3">
{group.label}
</p>
<ul className="flex flex-col gap-0.5">
{group.items.map(c => {
const isActive = active === c.id
return (
<li key={c.id}>
<button
type="button"
onClick={() => onSelect(c.id)}
className={`relative w-full flex items-center gap-2.5 h-9 pl-3 pr-3 rounded-md transition-colors duration-150 focus-ring ${
isActive ? 'bg-glass-light text-accent' : 'text-text-3 hover:text-text-1 hover:bg-glass-light/60'
}`}
>
{isActive && (
<motion.span
layoutId="settings-active-rail"
className="absolute left-0 top-1.5 bottom-1.5 w-[2.5px] rounded-r-full bg-accent shadow-[0_0_6px_rgba(245,182,66,0.55)]"
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
/>
)}
<c.icon size={14} stroke={isActive ? 2.2 : 1.75} className="shrink-0" />
<span className="text-[12.5px] font-medium tracking-tight truncate">{c.label}</span>
</button>
</li>
)
})}
</ul>
</li>
))}
</ul>
</nav>
)
}
+386
View File
@@ -0,0 +1,386 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Clock, Flame, Film, Tv, Award, Activity, CalendarStar } from '../lib/icons'
import { useLibraryItems } from '../hooks/use-jellyfin'
import { useDiary } from '../stores/diary-store'
import { genreBreakdown, totalHoursWatched, longestBinge, watchStreak } from '../lib/watch-stats'
import {
hoursPerGenre,
hoursPerStudio,
completion,
topByPersonRole,
totalTimeSavedSeconds,
} from '../lib/stats'
import { formatTimeSaved } from '../lib/time-saved'
const CURRENT_YEAR = new Date().getFullYear()
/**
* Detailed stats / year-in-watching. Profile page surfaces the at-a-glance
* summary; this one digs deeper into hours-weighted breakdowns, top
* directors / actors, completion ratios, and the time-saved-by-skipping
* total across every series.
*/
export default function StatsPage() {
const navigate = useNavigate()
const { data, isLoading } = useLibraryItems(undefined, {
includeItemTypes: ['Movie', 'Series', 'Episode'],
sortBy: ['DatePlayed'],
sortOrder: ['Descending'],
filters: ['IsPlayed'],
limit: 2000,
includePeople: true,
})
const items = useMemo(() => data?.Items || [], [data?.Items])
const yearItems = useMemo(
() =>
items.filter(it => {
const raw = it.UserData?.LastPlayedDate
if (!raw) return false
return new Date(raw).getFullYear() === CURRENT_YEAR
}),
[items],
)
const totalHoursAll = useMemo(() => totalHoursWatched(items), [items])
const totalHoursYear = useMemo(() => totalHoursWatched(yearItems), [yearItems])
const streak = useMemo(() => watchStreak(items), [items])
const binge = useMemo(() => longestBinge(items), [items])
const genreShare = useMemo(() => genreBreakdown(items).slice(0, 10), [items])
const hoursGenre = useMemo(() => hoursPerGenre(items).slice(0, 10), [items])
const hoursStudio = useMemo(() => hoursPerStudio(items).slice(0, 8), [items])
const comp = useMemo(() => completion(items), [items])
const topDirectors = useMemo(
() => topByPersonRole(items, (_role, type) => type === 'Director', 8),
[items],
)
const topActors = useMemo(
() => topByPersonRole(items, (_role, type) => type === 'Actor', 10),
[items],
)
const timeSaved = useMemo(() => totalTimeSavedSeconds(), [])
const diary = useDiary(s => s.entries)
const diaryYearCount = useMemo(
() =>
diary.filter(d => {
const t = Date.parse(d.watchedAt)
return Number.isFinite(t) && new Date(t).getFullYear() === CURRENT_YEAR
}).length,
[diary],
)
const moviesCount = useMemo(() => items.filter(i => i.Type === 'Movie').length, [items])
const episodesCount = useMemo(() => items.filter(i => i.Type === 'Episode').length, [items])
return (
<div className="px-7 pb-12 pt-6">
<header className="mb-8">
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
Stats
</p>
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
Your year in watching
</h1>
<p className="text-[13px] text-text-3 mt-1.5">
A deeper look at everything you've played - {items.length.toLocaleString()} {items.length === 1 ? 'item' : 'items'} tracked.
</p>
</header>
{isLoading && items.length === 0 && (
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
<p className="text-[13px] text-text-2 font-medium">Crunching the numbers...</p>
</div>
)}
{/* Headline tiles */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3 mb-10">
<StatTile
label={`Hours in ${CURRENT_YEAR}`}
value={totalHoursYear.toFixed(0)}
unit="hours"
icon={<Clock size={14} className="text-accent" />}
hint={`${totalHoursAll.toFixed(0)} all-time`}
accent
/>
<StatTile
label="Movies played"
value={moviesCount.toString()}
icon={<Film size={14} className="text-accent" />}
/>
<StatTile
label="Episodes played"
value={episodesCount.toString()}
icon={<Tv size={14} className="text-accent" />}
/>
<StatTile
label="Time saved skipping"
value={formatTimeSaved(timeSaved.total)}
icon={<Activity size={14} className="text-accent" />}
hint={timeSaved.series > 0 ? `across ${timeSaved.series} ${timeSaved.series === 1 ? 'show' : 'shows'}` : 'no skips yet'}
/>
<StatTile
label="Current streak"
value={String(streak.current)}
unit={streak.current === 1 ? 'day' : 'days'}
icon={<Flame size={14} className="text-accent" />}
hint={streak.longest > streak.current ? `Best: ${streak.longest}` : 'Keep going'}
/>
<StatTile
label="Diary entries"
value={String(diaryYearCount)}
unit={diaryYearCount === 1 ? `entry in ${CURRENT_YEAR}` : `entries in ${CURRENT_YEAR}`}
icon={<CalendarStar size={14} className="text-accent" />}
/>
</div>
{/* Two-column: hours per genre + hours per studio */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<Section title="Hours by genre" empty={hoursGenre.length === 0 ? 'No genre runtime data yet.' : null}>
<div className="space-y-2">
{hoursGenre.map((g, i) => (
<Bar key={g.label} label={g.label} share={g.share} index={i} suffix={`${g.hours.toFixed(0)}h`} />
))}
</div>
</Section>
<Section title="Top studios" empty={hoursStudio.length === 0 ? 'No studio info on your library.' : null}>
<div className="space-y-2">
{hoursStudio.map((s, i) => (
<Bar key={s.label} label={s.label} share={s.share} index={i} suffix={`${s.hours.toFixed(0)}h`} />
))}
</div>
</Section>
</div>
{/* Completion ratios */}
<Section title="Completion" className="mb-10">
<div className="grid grid-cols-3 gap-3">
<CompletionTile label="Finished" value={comp.completed} total={comp.total} accent />
<CompletionTile label="In progress" value={comp.inProgress} total={comp.total} />
<CompletionTile label="Untouched" value={comp.unstarted} total={comp.total} />
</div>
<p className="mt-3 text-[12px] text-text-3 tabular-nums">
{(comp.completionRate * 100).toFixed(1)}% of tracked items reached the finish line.
</p>
</Section>
{/* People */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<Section title="Most-watched directors" empty={topDirectors.length === 0 ? 'No director credits on items.' : null}>
<PersonList rows={topDirectors} onClick={n => navigate(`/search?q=${encodeURIComponent(n)}`)} />
</Section>
<Section title="Most-seen actors" empty={topActors.length === 0 ? 'No cast info on items.' : null}>
<PersonList rows={topActors} onClick={n => navigate(`/search?q=${encodeURIComponent(n)}`)} />
</Section>
</div>
{/* Side note: longest binge + breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<Section title="Longest binge">
{binge.count > 0 ? (
<div>
<p className="text-[28px] font-display font-bold text-text-1 tabular-nums leading-none">
{binge.count} <span className="text-[14px] font-medium text-text-3">items in one day</span>
</p>
<p className="text-[12.5px] text-text-3 mt-2">
On {binge.day ? new Date(binge.day + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : 'unknown'}.
</p>
</div>
) : (
<p className="text-[12.5px] text-text-3">No watched items yet.</p>
)}
</Section>
<Section title="Genre share (by count)" empty={genreShare.length === 0 ? null : null}>
<div className="space-y-2">
{genreShare.map((g, i) => (
<Bar key={g.genre} label={g.genre} share={g.share} index={i} suffix={`${g.count}`} />
))}
</div>
</Section>
</div>
{/* Time saved breakdown */}
<Section title="Time saved" className="mb-10">
{timeSaved.total > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<BadgeTile label="Total" value={formatTimeSaved(timeSaved.total)} icon={<Award size={14} className="text-accent" />} />
<BadgeTile label="Intros skipped" value={formatTimeSaved(timeSaved.intros)} />
<BadgeTile label="Credits skipped" value={formatTimeSaved(timeSaved.credits)} />
</div>
) : (
<p className="text-[12.5px] text-text-3">No auto-skips recorded yet. Turn intro / credits skipping on in Playback settings.</p>
)}
</Section>
</div>
)
}
function StatTile({
label,
value,
unit,
hint,
icon,
accent,
}: {
label: string
value: string
unit?: string
hint?: string
icon?: React.ReactNode
accent?: boolean
}) {
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className={`rounded-xl p-4 ring-1 ${
accent ? 'bg-accent/10 ring-accent/30' : 'bg-elevated/40 ring-border'
}`}
>
<div className="flex items-center gap-2 text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
{icon}
{label}
</div>
<div className="flex items-baseline gap-1.5">
<span className="font-display text-[26px] font-bold text-text-1 tabular-nums leading-none">
{value}
</span>
{unit && <span className="text-[12px] text-text-3 tracking-tight">{unit}</span>}
</div>
{hint && <p className="text-[11px] text-text-4 mt-1.5 tracking-tight">{hint}</p>}
</motion.div>
)
}
function Section({
title,
children,
empty,
className,
}: {
title: string
children: React.ReactNode
empty?: string | null
className?: string
}) {
return (
<section className={`rounded-xl bg-elevated/30 border border-border p-5 ${className || ''}`}>
<h2 className="text-[13px] font-semibold text-text-1 mb-3 tracking-tight">{title}</h2>
{empty ? <p className="text-[12.5px] text-text-3">{empty}</p> : children}
</section>
)
}
function Bar({
label,
share,
index,
suffix,
}: {
label: string
share: number
index: number
suffix?: string
}) {
const pct = Math.max(2, Math.round(share * 100))
return (
<motion.div
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: Math.min(index * 0.04, 0.4) }}
className="flex items-center gap-3 text-[12px]"
>
<span className="w-32 shrink-0 text-text-2 font-medium tracking-tight truncate">
{label}
</span>
<div className="flex-1 h-2.5 rounded-full bg-elevated/60 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="h-full bg-accent"
/>
</div>
<span className="w-16 shrink-0 text-right text-text-3 tabular-nums">
{suffix ?? `${pct}%`}
</span>
</motion.div>
)
}
function CompletionTile({
label,
value,
total,
accent,
}: {
label: string
value: number
total: number
accent?: boolean
}) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0
return (
<div className={`rounded-lg p-4 ring-1 ${accent ? 'bg-accent/8 ring-accent/25' : 'bg-elevated/40 ring-border'}`}>
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5">{label}</p>
<p className="font-display text-[22px] font-bold text-text-1 tabular-nums leading-none">
{value.toLocaleString()}
</p>
<p className="text-[11px] text-text-4 mt-1 tabular-nums">{pct}%</p>
</div>
)
}
function PersonList({
rows,
onClick,
}: {
rows: { name: string; count: number }[]
onClick: (name: string) => void
}) {
return (
<ol className="space-y-1.5">
{rows.map((p, i) => (
<li key={p.name}>
<button
onClick={() => onClick(p.name)}
className="w-full flex items-center gap-3 px-2 py-1.5 rounded-md hover:bg-white/4 transition-colors text-left focus-ring"
>
<span className="w-6 h-6 grid place-items-center rounded-full bg-accent/15 text-accent text-[10.5px] font-bold tabular-nums shrink-0">
{i + 1}
</span>
<span className="flex-1 text-[12.5px] text-text-1 font-medium truncate tracking-tight">
{p.name}
</span>
<span className="text-[11px] text-text-3 tabular-nums">
{p.count} {p.count === 1 ? 'item' : 'items'}
</span>
</button>
</li>
))}
</ol>
)
}
function BadgeTile({
label,
value,
icon,
}: {
label: string
value: string
icon?: React.ReactNode
}) {
return (
<div className="rounded-lg p-3.5 ring-1 ring-border bg-elevated/40">
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5 flex items-center gap-1.5">
{icon}
{label}
</p>
<p className="font-display text-[20px] font-bold text-text-1 tabular-nums leading-none">{value}</p>
</div>
)
}
+489
View File
@@ -0,0 +1,489 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
ArrowLeft, Clock, Star, Calendar, ExternalLink, Globe, Library,
} from '../lib/icons'
import { useTmdbDetailEnrichment } from '../hooks/use-tmdb-detail'
import { useLibraryByTmdbId } from '../hooks/use-jellyfin'
import { useFanartMovie, useFanartTv } from '../hooks/use-external'
import { getTmdbImageUrl, pickTmdbLogo } from '../api/tmdb'
import { pickBestFanartImage } from '../api/fanart'
import { mapTmdbToJf } from '../lib/tmdb-mapping'
import ContentRow from '../components/ui/ContentRow'
import CrewGrid from '../components/detail/CrewGrid'
import ComposerBlock from '../components/detail/ComposerBlock'
import VideosSection from '../components/detail/VideosSection'
import AwardsBlock from '../components/detail/AwardsBlock'
import ProductionTrivia from '../components/detail/ProductionTrivia'
import FilmingLocationsMap from '../components/detail/FilmingLocationsMap'
import CollectionStrip from '../components/detail/CollectionStrip'
import ReadingMode from '../components/detail/ReadingMode'
import DetailStickyBar from '../components/detail/DetailStickyBar'
import { usePastSentinel } from '../hooks/use-past-sentinel'
import RequestButton from '../components/request/RequestButton'
import AvailabilityChip from '../components/ui/AvailabilityChip'
import HorizontalScroller from '../components/ui/HorizontalScroller'
interface Props {
tmdbId: number
kind: 'movie' | 'tv'
}
/**
* Detail page for items the user does NOT have in their library. We
* render purely from TMDB metadata so users can browse missing canon,
* recommendations, awards-row picks, etc. without hitting Jellyfin.
*
* Mirrors the local DetailPage's chrome where it makes sense (cast,
* crew, videos, awards, trivia) but drops sections that need a local
* file (Play, Resume, Personal, Diary, Episodes list). The primary
* action becomes Request when *arr is configured.
*/
export default function TmdbDetailPage({ tmdbId, kind }: Props) {
const navigate = useNavigate()
const { sentinelRef: stickySentinelRef, past: pastHero } = usePastSentinel()
const enrichment = useTmdbDetailEnrichment({ kind, tmdbId })
const { data, isLoading, collection: tmdbCollection, awards: awardsQuery, locations: locationsQuery, wikiProduction: wikiProductionQuery } = enrichment
const libraryByTmdbId = useLibraryByTmdbId()
// Logo source priority: fanart.tv (cleaner art, typically transparent
// PNG wordmarks) → TMDB logos. Mirrors the library DetailHero's chain
// (jellyfin → fanart) but without the jellyfin source since this page
// exists precisely for items not in the library.
const tvdbId = (enrichment.data as any)?.external_ids?.tvdb_id
const fanartMovieQuery = useFanartMovie(kind === 'movie' ? String(tmdbId) : null)
const fanartTvQuery = useFanartTv(kind === 'tv' && tvdbId ? String(tvdbId) : null)
const fanartLogo = pickBestFanartImage(
kind === 'movie'
? fanartMovieQuery.data?.hdmovielogo || fanartMovieQuery.data?.movielogo
: fanartTvQuery.data?.hdtvlogo || fanartTvQuery.data?.clearlogo,
)
const tmdbLogo = pickTmdbLogo(enrichment.data?.images?.logos)
const logoUrl =
fanartLogo?.url ||
(tmdbLogo ? getTmdbImageUrl(tmdbLogo.file_path, 'w500') : null)
const [readingModeOpen, setReadingModeOpen] = useState(false)
// Library cross-reference: did the item get pulled into Jellyfin since
// we landed on this page? If so, offer a "View in your library" button.
const matchedLocal = libraryByTmdbId.data?.get(String(tmdbId)) || null
// Recommendations + similar rows (cross-referenced with library so
// already-owned items get the In Library indicator).
const recommendations = useMemo(() => {
const list = data?.recommendations?.results || []
return mapTmdbToJf(list.slice(0, 18), libraryByTmdbId.data)
}, [data, libraryByTmdbId.data])
const similar = useMemo(() => {
const list = data?.similar?.results || []
return mapTmdbToJf(list.slice(0, 18), libraryByTmdbId.data)
}, [data, libraryByTmdbId.data])
if (isLoading) {
return <Skeleton />
}
if (!data) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<p className="text-[15px] text-text-1 font-medium mb-1.5">No TMDB record</p>
<p className="text-[12px] text-text-3 max-w-md leading-relaxed mb-5">
We couldn't load metadata for this title. It may have been removed from TMDB.
</p>
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-elevated/60 ring-1 ring-border hover:ring-border-strong text-[12px] text-text-2 hover:text-text-1 transition focus-ring"
>
<ArrowLeft size={12} stroke={2} />
Back
</button>
</div>
)
}
const title = data.title || data.name || ''
const year = (data.release_date || data.first_air_date || '').slice(0, 4)
const runtime = kind === 'movie'
? data.runtime
: data.episode_run_time?.[0]
const overview = data.overview || ''
const tagline = data.tagline
const genres = data.genres || []
const community = data.vote_average
const certification = (() => {
if (kind === 'movie') {
const us = data.release_dates?.results?.find(r => r.iso_3166_1 === 'US')
return us?.release_dates?.find(d => d.certification)?.certification
}
return data.content_ratings?.results?.find(r => r.iso_3166_1 === 'US')?.rating
})()
const credits = data.credits
const cast = (credits?.cast || []).slice(0, 18)
const crew = credits?.crew || []
const backdrop = data.backdrop_path
? getTmdbImageUrl(data.backdrop_path, 'original')
: null
const poster = data.poster_path
? getTmdbImageUrl(data.poster_path, 'w500')
: null
const tmdbWatchUrl = `https://www.themoviedb.org/${kind === 'movie' ? 'movie' : 'tv'}/${tmdbId}`
const keywords = (data.keywords?.keywords || data.keywords?.results || []) as Array<{ id: number; name: string }>
// If the item was pulled into the library, route Play to the local
// playback URL; otherwise the sticky bar's Play button has nothing
// to do, so we hide it by passing an empty url and a no-op resume.
const playUrl = matchedLocal ? `/play/${matchedLocal.id}` : ''
const tmdbImdbId = (data as any).external_ids?.imdb_id || null
return (
<div className="pb-12">
{matchedLocal && (
<DetailStickyBar
visible={pastHero}
title={title}
logoUrl={logoUrl}
posterUrl={poster}
progress={null}
isFavorite={false}
playUrl={playUrl}
resumeUrl={playUrl}
tmdbId={tmdbId}
imdbId={tmdbImdbId}
itemType={kind === 'tv' ? 'Series' : 'Movie'}
/>
)}
{/* Hero */}
<section className="relative -mt-14 mb-2 overflow-hidden">
{backdrop && (
<motion.div
initial={{ opacity: 0, scale: 1.04 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-0 bg-cover bg-top will-change-transform"
style={{ backgroundImage: `url(${backdrop})` }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
<div className="relative px-7 pt-32 pb-10 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-8">
{poster && (
<motion.img
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
src={poster}
alt={title}
className="w-[180px] aspect-[2/3] rounded-xl object-cover ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] hidden md:block"
/>
)}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
className="max-w-3xl"
>
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-cool/12 border border-cool/30 text-cool text-[10px] font-semibold uppercase tracking-[0.14em]">
<Globe size={10} />
Discovery
</span>
<AvailabilityChip tmdbId={tmdbId} />
{matchedLocal && (
<button
onClick={() => navigate(`/item/${matchedLocal.id}`)}
className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-accent text-void text-[10px] font-bold uppercase tracking-[0.06em] hover:bg-accent-hover transition"
>
<Library size={10} strokeWidth={2.5} />
In your library
</button>
)}
</div>
{logoUrl ? (
<div className="mb-4 flex justify-start">
<img
src={logoUrl}
alt={title}
className="block h-auto max-h-32 md:max-h-40 max-w-[460px] w-auto drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
onError={e => {
const el = e.target as HTMLImageElement
el.style.display = 'none'
// Reveal the text fallback if the image fails to load.
const fallback = el.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = 'block'
}}
/>
<h1
className="font-display text-4xl md:text-5xl font-bold text-white leading-[0.98] tracking-tight mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)] hidden"
>
{title}
</h1>
</div>
) : (
<h1 className="font-display text-4xl md:text-5xl font-bold text-white leading-[0.98] tracking-tight mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)]">
{title}
</h1>
)}
{tagline && (
<p className="text-[15px] text-white/70 italic mb-3 max-w-xl font-display">
"{tagline}"
</p>
)}
<div className="flex items-center gap-2.5 text-[12px] text-white/75 mb-3 flex-wrap font-medium">
{year && <span className="tabular-nums">{year}</span>}
{certification && (
<>
<Dot />
<span className="px-1.5 py-0.5 border border-white/25 rounded text-[10px] font-semibold tracking-wide">
{certification}
</span>
</>
)}
{runtime != null && runtime > 0 && (
<>
<Dot />
<span className="inline-flex items-center gap-1 tabular-nums">
<Clock size={11} stroke={2} />
{runtime}m
</span>
</>
)}
{community != null && community > 0 && (
<>
<Dot />
<span className="inline-flex items-center gap-1">
<Star size={11} className="text-accent" fill="currentColor" stroke={0} />
<span className="tabular-nums">{Number(community).toFixed(1)}</span>
</span>
</>
)}
{kind === 'tv' && (data as any).number_of_seasons > 0 && (
<>
<Dot />
<span className="inline-flex items-center gap-1 tabular-nums">
<Calendar size={11} stroke={2} />
{(data as any).number_of_seasons} season{(data as any).number_of_seasons === 1 ? '' : 's'}
</span>
</>
)}
</div>
{genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-5">
{genres.slice(0, 5).map(g => (
<span
key={g.id}
className="px-2.5 h-6 inline-flex items-center bg-white/8 backdrop-blur text-white/85 text-[11px] rounded-full border border-white/8"
>
{g.name}
</span>
))}
</div>
)}
{overview && (
<p className="text-[14px] text-white/85 leading-[1.65] mb-6 line-clamp-4 max-w-xl drop-shadow-sm">
{overview}
</p>
)}
<div className="flex items-center gap-2.5 flex-wrap">
<RequestButton tmdbId={tmdbId} kind={kind} tmdbData={data as any} />
{matchedLocal && (
<button
onClick={() => navigate(`/item/${matchedLocal.id}`)}
className="h-11 px-5 bg-white/10 text-white border border-white/20 backdrop-blur rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:bg-white/15 hover:border-white/30 hover:scale-[1.02] active:scale-[0.98] focus-ring"
>
<Library size={14} stroke={2} />
Open in library
</button>
)}
<a
href={tmdbWatchUrl}
target="_blank"
rel="noopener noreferrer"
className="h-11 px-4 bg-white/10 hover:bg-white/15 text-white border border-white/15 backdrop-blur rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus-ring"
>
Open on TMDB
<ExternalLink size={12} />
</a>
</div>
</motion.div>
</div>
</section>
<div ref={stickySentinelRef} aria-hidden className="h-px -mt-px" />
{/* Body */}
<div className="px-7 space-y-12 pt-4">
{overview && (
<Section label="Overview">
<p className="text-[15px] text-text-2 leading-[1.7] max-w-[68ch] tracking-[-0.005em]">
{overview}
</p>
{overview.length > 600 && (
<button
onClick={() => setReadingModeOpen(true)}
className="mt-2 text-[11.5px] text-accent hover:text-accent-hover transition tracking-tight focus-ring rounded"
>
Open in reader
</button>
)}
</Section>
)}
<ReadingMode
open={readingModeOpen}
onClose={() => setReadingModeOpen(false)}
title={title}
body={overview}
/>
{cast.length > 0 && (
<Section label="Cast">
<CastStrip cast={cast} />
</Section>
)}
{crew.length > 0 && (
<Section label="Crew">
<CrewGrid crew={crew} />
<div className="mt-3">
<ComposerBlock crew={crew} />
</div>
</Section>
)}
{((data as any).videos?.results?.length ?? 0) > 0 && (
<Section label="Videos">
<VideosSection videos={(data as any).videos?.results} />
</Section>
)}
{(awardsQuery.data?.length ?? 0) > 0 && (
<Section label="Awards">
<AwardsBlock awards={awardsQuery.data} />
</Section>
)}
{(wikiProductionQuery.data?.extract || (keywords.length > 0 && wikiProductionQuery.data)) && (
<Section label="Trivia">
<ProductionTrivia keywords={keywords} wikiProduction={wikiProductionQuery.data} />
</Section>
)}
{(locationsQuery.data?.length ?? 0) > 0 && (
<Section label="Filming locations">
<FilmingLocationsMap locations={locationsQuery.data} />
</Section>
)}
{kind === 'movie' && (data as any).belongs_to_collection && (
<Section label="Collection">
<CollectionStrip
collectionRef={(data as any).belongs_to_collection}
collection={tmdbCollection.data}
currentMovieId={tmdbId}
/>
</Section>
)}
</div>
{/* Recommendations / similar */}
{recommendations.length > 0 && (
<div className="mt-10">
<ContentRow
title="More like this"
subtitle="TMDB recommendations"
items={recommendations}
layoutKey={`tmdb_${kind}_${tmdbId}_recs`}
/>
</div>
)}
{similar.length > 0 && (
<ContentRow
title="Similar"
subtitle="Same flavour, different titles"
items={similar}
layoutKey={`tmdb_${kind}_${tmdbId}_similar`}
/>
)}
</div>
)
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-4 leading-none">
{label}
</p>
{children}
</section>
)
}
function CastStrip({ cast }: { cast: any[] }) {
const navigate = useNavigate()
return (
<div className="-mx-7">
<HorizontalScroller gap="gap-3" arrowsBottomInset="bottom-12">
{cast.map(c => (
<button
key={c.id}
onClick={() => navigate(`/person/${c.id}`)}
className="shrink-0 w-[112px] text-left focus-ring rounded-lg group"
>
<div className="aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-border-strong mb-1.5 transition">
{c.profile_path ? (
<img
src={getTmdbImageUrl(c.profile_path, 'w185')}
alt={c.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-4 text-2xl font-display font-semibold opacity-40">
{c.name?.[0]?.toUpperCase() || '?'}
</div>
)}
</div>
<p className="text-[12px] text-text-1 font-medium leading-tight tracking-tight truncate">
{c.name}
</p>
{c.character && (
<p className="text-[10.5px] text-text-3 leading-tight truncate">
{c.character}
</p>
)}
</button>
))}
</HorizontalScroller>
</div>
)
}
function Skeleton() {
return (
<div className="pb-12">
<div className="relative -mt-14 mb-2 h-[60vh] min-h-[440px] overflow-hidden">
<div className="absolute inset-0 skeleton" />
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/55 to-transparent" />
</div>
<div className="px-7 space-y-8 pt-6">
<div className="skeleton h-5 w-32 rounded" />
<div className="skeleton h-4 w-full rounded" />
<div className="skeleton h-4 w-5/6 rounded" />
<div className="skeleton h-4 w-2/3 rounded" />
</div>
</div>
)
}
function Dot() {
return <span className="text-white/30">·</span>
}