detail page components

This commit is contained in:
2026-03-27 23:06:44 +02:00
parent 02f0f58ec9
commit a039249ede
41 changed files with 6470 additions and 0 deletions
+465
View File
@@ -0,0 +1,465 @@
import { motion } from 'framer-motion'
import { useNavigate, type NavigateFunction } from 'react-router-dom'
import {
Play,
Heart,
Clock,
Star,
Plus,
Share2,
ChevronRight,
RefreshCw,
Tv2,
Film as FilmIcon,
Disc3,
} from '../../lib/icons'
import type { BaseItemDto } from '../../api/types'
import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb'
import type { CinemetaMeta } from '../../api/cinemeta'
import MetaBadges from '../ui/MetaBadges'
import WatchlistButton from '../ui/WatchlistButton'
import RequestButton from '../request/RequestButton'
import ExternalLinks from './ExternalLinks'
import HeroTechCard from './HeroTechCard'
import CastMenu from './CastMenu'
import { useYoutubeViewer } from '../../stores/youtube-viewer-store'
import { usePreferencesStore } from '../../stores/preferences-store'
import type { useRefreshItem, useSampleEpisode } from '../../hooks/use-jellyfin'
import { buildShareUrl, copyToClipboard } from '../../lib/share'
import { toast } from '../../stores/toast-store'
interface Props {
item: BaseItemDto
itemType: string
tmdbMovieData: TmdbMovie | null | undefined
tmdbTvData: TmdbTvShow | null | undefined
tmdbId: string | null | undefined
posterUrl: string
logoUrl: string | null
backdropUrl: string | null
title: string
year?: number | null
rating: string | null | undefined
runtime: string | null
taglineText: string | null | undefined
genres: string[]
resumeTime: string | null
progress: number | null | undefined
isFavorite: boolean | null | undefined
playUrl: string
resumeUrl: string
trailer: { key: string; official?: boolean } | null | undefined
cinemetaData: CinemetaMeta | null | undefined
showTmdbRatings: boolean
sampleEpisode: ReturnType<typeof useSampleEpisode>
refresh: ReturnType<typeof useRefreshItem>
}
export default function DetailHero({
item,
itemType,
tmdbMovieData,
tmdbTvData,
tmdbId,
posterUrl,
logoUrl,
backdropUrl,
title,
year,
rating,
runtime,
taglineText,
genres,
resumeTime,
progress,
isFavorite,
playUrl,
resumeUrl,
trailer,
cinemetaData,
showTmdbRatings,
sampleEpisode,
refresh,
}: Props) {
const navigate = useNavigate()
const tmdbData = itemType === 'Movie' ? tmdbMovieData : tmdbTvData
const preRollTrailers = usePreferencesStore(s => s.preRollTrailers)
const youtube = useYoutubeViewer()
function handlePlay() {
if (preRollTrailers && trailer && itemType === 'Movie') {
youtube.show({
videoKey: trailer.key,
title: `${title} - Trailer`,
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
onClose: () => navigate(playUrl),
})
return
}
navigate(playUrl)
}
return (
<div className="relative h-[68vh] min-h-[520px] -mt-14 overflow-hidden">
{backdropUrl && (
<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(${backdropUrl})` }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-void/10" />
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_75%_25%,transparent_0%,rgba(0,0,0,0.5)_85%)]" />
<div className="relative h-full flex items-end pb-12 px-7 gap-7">
{/* Left column: type pill + poster */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
className="hidden md:flex flex-col gap-3 shrink-0"
>
{itemType && <TypePill itemType={itemType} />}
{posterUrl && (
<motion.img
src={posterUrl}
alt={title}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
className="w-[180px] aspect-[2/3] object-cover rounded-xl ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)]"
/>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
className="flex-1 min-w-0 max-w-3xl"
>
{/* Type pill (small screens only - shown above poster on md+) */}
<div className="flex items-center gap-2 mb-4 flex-wrap md:hidden">
{itemType && <TypePill itemType={itemType} />}
<MetaBadges item={item} variant="hero" />
</div>
{itemType === 'Episode' && item.SeriesId && (
<EpisodeBreadcrumb item={item} navigate={navigate} />
)}
{logoUrl ? (
<div className="mb-4 flex justify-start">
<img
src={logoUrl}
alt={title}
className="block h-auto max-h-40 md:max-h-48 max-w-[520px] w-auto drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
) : (
<h1 className="font-display text-5xl md:text-6xl font-bold text-white leading-[0.95] tracking-tight mb-4 text-left drop-shadow-[0_4px_16px_rgba(0,0,0,0.55)]">
{title}
</h1>
)}
{taglineText && (
<p className="text-white/65 text-[15px] italic mb-3 max-w-xl font-display">
"{taglineText}"
</p>
)}
<div className="flex items-center gap-2.5 text-[12px] text-white/70 font-medium mb-3 flex-wrap">
{year && <span className="tabular-nums">{year}</span>}
{year && (rating || runtime) && <Dot />}
{rating && (
<span className="px-1.5 py-0.5 border border-white/25 rounded text-[10px] font-semibold tracking-wide">
{rating}
</span>
)}
{rating && runtime && <Dot />}
{runtime && (
<span className="flex items-center gap-1 tabular-nums">
<Clock size={11} /> {runtime}
</span>
)}
{/* Ratings shown on the right-side card (and here only on small screens for accessibility) */}
<span className="lg:hidden flex items-center gap-2.5">
{showTmdbRatings && tmdbData?.vote_average != null && tmdbData.vote_average > 0 && (
<>
<Dot />
<span className="flex items-center gap-1" title="TMDB community score">
<Star size={11} className="text-accent fill-accent" />
<span className="tabular-nums">{tmdbData.vote_average.toFixed(1)}</span>
<span className="text-white/45 text-[10px] uppercase tracking-wide">TMDB</span>
</span>
</>
)}
{item.CommunityRating != null && item.CommunityRating > 0 && (
<>
<Dot />
<span className="flex items-center gap-1 text-cool" title="Jellyfin community average">
<Star size={11} className="fill-current" />
<span className="tabular-nums">{item.CommunityRating.toFixed(1)}</span>
<span className="text-white/45 text-[10px] uppercase tracking-wide">Jellyfin</span>
</span>
</>
)}
</span>
{cinemetaData?.imdbRating && Number(cinemetaData.imdbRating) > 0 && (
<>
<Dot />
<span className="flex items-center gap-1" title="IMDB rating">
<Star size={11} className="text-yellow-400 fill-yellow-400" />
<span className="tabular-nums">{Number(cinemetaData.imdbRating).toFixed(1)}</span>
<span className="text-white/45 text-[10px] uppercase tracking-wide">IMDB</span>
</span>
</>
)}
</div>
{genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{genres.slice(0, 5).map(g => (
<span
key={g}
className="px-2.5 h-6 inline-flex items-center bg-white/8 hover:bg-white/12 backdrop-blur text-white/80 text-[11px] rounded-full border border-white/8 transition-colors cursor-default"
>
{g}
</span>
))}
</div>
)}
<div className="flex items-center gap-2 flex-wrap mb-5">
<button
onClick={handlePlay}
className="group h-11 px-6 bg-white text-void rounded-lg flex items-center gap-2 text-[14px] font-semibold transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-black/30 focus-ring"
>
<Play size={16} strokeWidth={0} fill="currentColor" />
Play
</button>
{resumeTime && (
<button
onClick={() => navigate(resumeUrl)}
className="h-11 px-4 bg-accent/15 text-accent border border-accent/30 hover:bg-accent/20 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"
>
<Play size={14} strokeWidth={0} fill="currentColor" />
Resume from <span className="tabular-nums">{resumeTime}</span>
</button>
)}
{trailer && (
<button
onClick={() =>
useYoutubeViewer.getState().show({
videoKey: trailer.key,
title: `${title} - Trailer`,
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
})
}
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"
>
Trailer
</button>
)}
{item.Id && (item.Type === 'Movie' || item.Type === 'Series') && (
<WatchlistButton itemId={item.Id} />
)}
{tmdbId && (item.Type === 'Movie' || item.Type === 'Series') && (
<RequestButton
tmdbId={Number(tmdbId)}
kind={item.Type === 'Movie' ? 'movie' : 'tv'}
tmdbData={tmdbData as any}
/>
)}
<IconButton aria-label={isFavorite ? 'Remove favorite' : 'Add favorite'} active={!!isFavorite}>
<Heart size={16} className={isFavorite ? 'text-accent fill-accent' : 'text-white/80'} />
</IconButton>
<IconButton aria-label="Add to playlist">
<Plus size={16} className="text-white/80" />
</IconButton>
<IconButton
aria-label="Copy share link"
title="Copy share link"
onClick={async () => {
const url = buildShareUrl({
tmdbId,
imdbId: item.ProviderIds?.Imdb,
kind: itemType === 'Series' || itemType === 'Episode' ? 'tv' : 'movie',
})
if (!url) {
toast('No shareable link for this item', 'error')
return
}
const ok = await copyToClipboard(url)
toast(ok ? 'Link copied to clipboard' : 'Could not copy link', ok ? 'success' : 'error')
}}
>
<Share2 size={16} className="text-white/80" />
</IconButton>
{item.Id && <CastMenu itemId={item.Id} mediaType={item.MediaType ?? undefined} />}
<IconButton
aria-label="Refresh metadata"
onClick={() => {
if (!item.Id) return
refresh.mutate({
itemId: item.Id,
replaceAllMetadata: false,
replaceAllImages: false,
})
}}
disabled={refresh.isPending}
title={
refresh.isPending
? 'Refreshing...'
: refresh.isSuccess
? 'Refresh queued'
: 'Re-scrape metadata + images from TMDB / TVDB'
}
>
<RefreshCw
size={16}
className={`text-white/80 ${refresh.isPending ? 'animate-[spin-soft_0.8s_linear_infinite]' : ''}`}
/>
</IconButton>
</div>
<ExternalLinks
tmdbId={tmdbId}
type={itemType === 'Series' ? 'tv' : 'movie'}
ids={tmdbData?.external_ids}
jellyfinExternalUrls={item.ExternalUrls as any}
/>
{progress != null && progress > 0 && (
<div className="mt-5 max-w-md">
<div className="flex items-center justify-between text-[10px] text-white/60 mb-1.5 font-medium uppercase tracking-wider">
<span>Watch progress</span>
<span className="tabular-nums">{Math.round(progress)}%</span>
</div>
<div className="h-1 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
className="h-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]"
/>
</div>
</div>
)}
</motion.div>
<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.35 }}
className="self-end"
>
<HeroTechCard
techItem={itemType === 'Series' ? sampleEpisode.data : item}
tmdbVote={tmdbData?.vote_average}
tmdbVoteCount={tmdbData?.vote_count}
jellyfinCommunity={item.CommunityRating}
jellyfinCritic={item.CriticRating}
imdbRating={
cinemetaData?.imdbRating
? Number(cinemetaData.imdbRating)
: null
}
showTmdbRatings={showTmdbRatings}
seasonsCount={
itemType === 'Series'
? tmdbTvData?.number_of_seasons ?? null
: null
}
episodesCount={
itemType === 'Series'
? tmdbTvData?.number_of_episodes ?? null
: null
}
seriesStatus={itemType === 'Series' ? tmdbTvData?.status ?? null : null}
onRescan={
item.Id
? () =>
refresh.mutate({
itemId: item.Id!,
replaceAllMetadata: false,
replaceAllImages: false,
})
: undefined
}
isRescanning={refresh.isPending}
/>
</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>
)
}
function TypePill({ itemType }: { itemType: string }) {
return (
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 self-start rounded-full bg-white/8 border border-white/12 backdrop-blur text-[10px] font-semibold uppercase tracking-[0.12em] text-white/80">
{itemType === 'Series' || itemType === 'Episode' ? (
<Tv2 size={10} className="text-accent" />
) : itemType === 'MusicAlbum' || itemType === 'MusicArtist' ? (
<Disc3 size={10} className="text-accent" />
) : (
<FilmIcon size={10} className="text-accent" />
)}
{itemType === 'Series' ? 'Series' : itemType === 'Movie' ? 'Movie' : itemType}
</span>
)
}
function EpisodeBreadcrumb({ item, navigate }: { item: BaseItemDto; navigate: NavigateFunction }) {
return (
<button
onClick={() => navigate(`/item/${item.SeriesId}`)}
className="inline-flex items-center gap-2 mb-3 group focus-ring rounded"
>
<span className="text-[11px] font-semibold text-white/70 uppercase tracking-[0.18em] group-hover:text-white transition">
{item.SeriesName}
</span>
{item.ParentIndexNumber != null && item.IndexNumber != null && (
<span className="text-[11px] text-white/50 font-medium tabular-nums">
S{item.ParentIndexNumber} · E{item.IndexNumber}
</span>
)}
<ChevronRight size={12} className="text-white/40 group-hover:text-white/80 group-hover:translate-x-0.5 transition" />
</button>
)
}
function Dot() {
return <span className="text-white/30">·</span>
}
function IconButton({
children,
active,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
return (
<button
{...props}
className={`w-11 h-11 rounded-lg backdrop-blur grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 focus-ring disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 ${
active
? 'bg-accent/15 border border-accent/30'
: 'bg-white/10 border border-white/15 hover:bg-white/15'
}`}
>
{children}
</button>
)
}