main pages
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user