detail page components
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
import { useState } from 'react'
|
||||
import { Tag } from '../../lib/icons'
|
||||
import { Section } from '../ui/SectionLabel'
|
||||
import type { BaseItemDto } from '../../api/types'
|
||||
import type {
|
||||
TmdbMovie,
|
||||
TmdbTvShow,
|
||||
TmdbCollection,
|
||||
TmdbWatchProviders,
|
||||
TmdbKeyword,
|
||||
TmdbReview,
|
||||
TmdbVideo,
|
||||
} from '../../api/tmdb'
|
||||
import type { CinemetaMeta } from '../../api/cinemeta'
|
||||
import type { TvmazeShow } from '../../api/tvmaze'
|
||||
import type { WikidataAward, WikidataLocation } from '../../api/wikidata'
|
||||
import type { RTRating } from '../../api/rotten-tomatoes'
|
||||
|
||||
type WikiSection = { extract: string; title: string }
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { useDiary } from '../../stores/diary-store'
|
||||
import { parseImdbRating } from '../../lib/episode-meta'
|
||||
import { hasTechSpecs } from './TechSpecs'
|
||||
import VersionsSelector from './VersionsSelector'
|
||||
import ReadingMode from './ReadingMode'
|
||||
import SeriesSection from './SeriesSection'
|
||||
import EpisodeExtras from './EpisodeExtras'
|
||||
import CastList from './CastList'
|
||||
import CrewGrid from './CrewGrid'
|
||||
import ComposerBlock from './ComposerBlock'
|
||||
import FactsPanel from './FactsPanel'
|
||||
import WhereToWatch from './WhereToWatch'
|
||||
import SeriesStatusBlock from './SeriesStatusBlock'
|
||||
import VideosSection from './VideosSection'
|
||||
import ProductionTrivia from './ProductionTrivia'
|
||||
import ReceptionPanel from './ReceptionPanel'
|
||||
import WatchTimeline from './WatchTimeline'
|
||||
import FilmingLocationsMap from './FilmingLocationsMap'
|
||||
import AwardsBlock from './AwardsBlock'
|
||||
import TechSpecs from './TechSpecs'
|
||||
import PersonalSection from './PersonalSection'
|
||||
import DiarySection from './DiarySection'
|
||||
import CollectionStrip from './CollectionStrip'
|
||||
import ReviewsSection from './ReviewsSection'
|
||||
import DiscoveryRows from './DiscoveryRows'
|
||||
import FromSameRow from './FromSameRow'
|
||||
import { getSeriesAirInfo } from '../../lib/item-types'
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto
|
||||
itemId: string | undefined
|
||||
itemType: string
|
||||
imdbId: string | null
|
||||
tmdbId: string | null | undefined
|
||||
tmdbMovieData: TmdbMovie | null | undefined
|
||||
tmdbTvData: TmdbTvShow | null | undefined
|
||||
tmdbCollectionData: TmdbCollection | null | undefined
|
||||
cinemetaData: CinemetaMeta | null | undefined
|
||||
tvmazeData: TvmazeShow | null | undefined
|
||||
wikiProduction: WikiSection | null | undefined
|
||||
awardsData: WikidataAward[] | null | undefined
|
||||
locationsData: WikidataLocation[] | null | undefined
|
||||
rtData: RTRating | null | undefined
|
||||
/** Wikipedia article title for the item - the ReceptionPanel uses
|
||||
* this to pull the Critical-response section. */
|
||||
wikiTitle: string | null
|
||||
region: string
|
||||
watchProviders: TmdbWatchProviders | null | undefined
|
||||
cast: unknown[]
|
||||
crew: unknown[]
|
||||
keywords: TmdbKeyword[]
|
||||
reviews: TmdbReview[]
|
||||
videos: TmdbVideo[] | undefined
|
||||
recommendations: { id: number; adult?: boolean }[]
|
||||
similar: { id: number; adult?: boolean }[]
|
||||
libraryMap: Map<string, { id: string; name: string; type: string }> | undefined
|
||||
overview: string
|
||||
overviewSource: string | null
|
||||
sources: { Id?: string }[]
|
||||
activeSourceId: string | null
|
||||
onSourceChange: (id: string | null) => void
|
||||
}
|
||||
|
||||
export default function DetailMainSections({
|
||||
item,
|
||||
itemId,
|
||||
itemType,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tmdbMovieData,
|
||||
tmdbTvData,
|
||||
tmdbCollectionData,
|
||||
cinemetaData,
|
||||
tvmazeData,
|
||||
wikiProduction,
|
||||
awardsData,
|
||||
locationsData,
|
||||
rtData,
|
||||
wikiTitle,
|
||||
region,
|
||||
watchProviders,
|
||||
cast,
|
||||
crew,
|
||||
keywords,
|
||||
reviews,
|
||||
videos,
|
||||
recommendations,
|
||||
similar,
|
||||
libraryMap,
|
||||
overview,
|
||||
overviewSource,
|
||||
sources,
|
||||
activeSourceId,
|
||||
onSourceChange,
|
||||
}: Props) {
|
||||
const prefs = usePreferencesStore()
|
||||
const hasDiaryEntries = useDiary(s => itemId ? s.entries.some(e => e.itemId === itemId) : false)
|
||||
const [readingModeOpen, setReadingModeOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative px-7 pt-2 space-y-10">
|
||||
{/* Versions selector when there's more than one source */}
|
||||
{sources.length > 1 && (
|
||||
<Section label="Versions">
|
||||
<VersionsSelector
|
||||
item={item}
|
||||
selectedSourceId={activeSourceId}
|
||||
onChange={onSourceChange}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Synopsis */}
|
||||
{overview && (
|
||||
<Section label="Overview" id="detail-overview">
|
||||
<p className="text-[15px] text-text-2 leading-[1.7] max-w-[68ch] tracking-[-0.005em] [text-wrap:pretty]">
|
||||
{overview}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3 flex-wrap">
|
||||
{overviewSource && (
|
||||
<p className="text-[10.5px] text-text-4">Source: {overviewSource}</p>
|
||||
)}
|
||||
{overview.length > 600 && (
|
||||
<button
|
||||
onClick={() => setReadingModeOpen(true)}
|
||||
className="text-[11.5px] text-accent hover:text-accent-hover transition tracking-tight focus-ring rounded"
|
||||
>
|
||||
Open in reader
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
<ReadingMode
|
||||
open={readingModeOpen}
|
||||
onClose={() => setReadingModeOpen(false)}
|
||||
title={item.Name || ''}
|
||||
body={overview}
|
||||
source={overviewSource}
|
||||
/>
|
||||
|
||||
{/* Watch timeline: the user's personal history with this title.
|
||||
Letterboxd-flavoured chronology of diary entries. Self-hides
|
||||
when there's no diary history. */}
|
||||
{hasDiaryEntries && itemId && (
|
||||
<Section label="Your watches">
|
||||
<WatchTimeline itemId={itemId} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Reception: aggregate of every available rating + critical
|
||||
response prose. Replaces the ratings strip that used to live
|
||||
inside the About / FactsPanel section. */}
|
||||
{(itemType === 'Movie' || itemType === 'Series') && (
|
||||
<Section label="Reception" id="detail-reception">
|
||||
<ReceptionPanel
|
||||
itemId={itemId}
|
||||
rt={rtData}
|
||||
imdbRating={parseImdbRating(cinemetaData?.imdbRating)}
|
||||
tmdbScore={(itemType === 'Movie' ? tmdbMovieData?.vote_average : tmdbTvData?.vote_average) ?? null}
|
||||
tmdbVotes={(itemType === 'Movie' ? tmdbMovieData?.vote_count : tmdbTvData?.vote_count) ?? null}
|
||||
jellyfinCommunityRating={item.CommunityRating ?? null}
|
||||
wikiTitle={wikiTitle}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Series episodes - lead with what the user came for on a series
|
||||
page. Full-width because the table scrolls and packs many rows. */}
|
||||
{itemType === 'Series' && itemId && (
|
||||
<div id="detail-episodes" className="scroll-mt-4">
|
||||
<SeriesSection seriesId={itemId} imdbId={imdbId} tmdbId={tmdbId ? Number(tmdbId) : null} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode-specific extras (only on Episode pages) */}
|
||||
{itemType === 'Episode' && prefs.detail.show.episodeExtras && (
|
||||
<Section label="">
|
||||
<EpisodeExtras item={item} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* People row: Cast + Crew side-by-side */}
|
||||
{(cast.length > 0 || crew.length > 0) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-10">
|
||||
{cast.length > 0 && (
|
||||
<Section label="Cast" id="detail-cast">
|
||||
<CastList cast={cast as any[]} fallbackPeople={item.People} />
|
||||
</Section>
|
||||
)}
|
||||
{crew.length > 0 && (
|
||||
<Section label="Crew">
|
||||
<CrewGrid crew={crew as any[]} />
|
||||
<div className="mt-3">
|
||||
<ComposerBlock crew={crew as any[]} />
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "What is this" cluster: About + Where to Watch + Series Status
|
||||
flow into a CSS multi-column layout. Items are packed by content
|
||||
height (newspaper-style) so a tall About doesn't leave a 600px
|
||||
void next to a short Where to Watch row. */}
|
||||
{((itemType === 'Movie' || itemType === 'Series') || watchProviders) && (
|
||||
<div className="columns-1 lg:columns-2 gap-8 [column-fill:balance]">
|
||||
{(itemType === 'Movie' || itemType === 'Series') && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<Section label="About">
|
||||
<FactsPanel
|
||||
itemType={itemType}
|
||||
movie={tmdbMovieData || null}
|
||||
tv={tmdbTvData || null}
|
||||
region={region}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{watchProviders && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<WhereToWatch providers={watchProviders} defaultRegion={region} />
|
||||
</div>
|
||||
)}
|
||||
{itemType === 'Series' && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<Section label="Series status">
|
||||
<SeriesStatusBlock
|
||||
show={tmdbTvData ?? undefined}
|
||||
tvmazeShow={tvmazeData || null}
|
||||
jellyfinAirDays={getSeriesAirInfo(item).airDays}
|
||||
jellyfinAirTime={getSeriesAirInfo(item).airTime}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{prefs.detail.show.videos && (videos?.length ?? 0) > 0 && (
|
||||
<Section label="Videos">
|
||||
<VideosSection videos={videos} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Trivia */}
|
||||
{prefs.detail.show.trivia && (wikiProduction?.extract || (keywords.length > 0 && wikiProduction)) && (
|
||||
<Section label="Trivia" id="detail-trivia">
|
||||
<ProductionTrivia
|
||||
keywords={keywords}
|
||||
wikiProduction={wikiProduction}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Filming locations */}
|
||||
{prefs.detail.show.filmingLocations && (locationsData?.length ?? 0) > 0 && (
|
||||
<Section label="Filming locations">
|
||||
<FilmingLocationsMap locations={locationsData} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Activity cluster: Awards + Tech specs + Personal + Diary in a
|
||||
multi-column flow so wildly different content heights pack
|
||||
efficiently. */}
|
||||
{((prefs.detail.show.awards && (awardsData?.length ?? 0) > 0)
|
||||
|| hasTechSpecs(item)
|
||||
|| keywords.length > 0
|
||||
|| (item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series'))) && (
|
||||
<div className="columns-1 lg:columns-2 gap-8 [column-fill:balance]">
|
||||
{prefs.detail.show.awards && (awardsData?.length ?? 0) > 0 && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<Section label="Awards">
|
||||
<AwardsBlock awards={awardsData} />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{hasTechSpecs(item) && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<Section label="Tech specs">
|
||||
<TechSpecs item={item} />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.personal && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<Section label="Personal">
|
||||
<PersonalSection itemId={item.Id} showRewatchToggle />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.diary && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<Section label="Diary">
|
||||
<DiarySection itemId={item.Id} itemName={item.Name || 'Unknown'} />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{keywords.length > 0 && (
|
||||
<div className="break-inside-avoid mb-8">
|
||||
<Section label="Keywords">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.slice(0, 24).map(k => (
|
||||
<span
|
||||
key={k.id}
|
||||
className="inline-flex items-center gap-1 h-6 px-2.5 bg-elevated/50 hover:bg-elevated border border-border hover:border-border-hover rounded-full text-[11px] text-text-2 hover:text-text-1 transition-colors cursor-default"
|
||||
>
|
||||
<Tag size={9} className="text-text-4" />
|
||||
{k.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collection - the franchise / series strip */}
|
||||
{tmdbMovieData?.belongs_to_collection && (
|
||||
<Section label="Collection">
|
||||
<CollectionStrip
|
||||
collectionRef={tmdbMovieData.belongs_to_collection}
|
||||
collection={tmdbCollectionData ?? undefined}
|
||||
currentMovieId={tmdbMovieData.id}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Reviews */}
|
||||
{reviews.length > 0 && (
|
||||
<Section label="Reviews" id="detail-reviews">
|
||||
<ReviewsSection reviews={reviews} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section id="detail-more" className="scroll-mt-4 mt-14 pt-8 border-t border-border/60">
|
||||
<div className="px-7 mb-6">
|
||||
<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]">
|
||||
More like this
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-text-3 max-w-xl">
|
||||
Titles by the same people on this project plus algorithmic picks
|
||||
you might want to queue up next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* "From the same..." rows: highlight other work by the headline
|
||||
creator + a top-billed actor before the generic Discovery rows.
|
||||
Each row self-hides when the person has fewer than 3 other
|
||||
credits in the same media kind, so this stays scoped. */}
|
||||
{(itemType === 'Movie' || itemType === 'Series') && (
|
||||
<FromSameRowsCluster
|
||||
itemType={itemType}
|
||||
tmdbId={tmdbId}
|
||||
cast={cast}
|
||||
crew={crew}
|
||||
libraryMap={libraryMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Discovery rows - library matches first, then full lists */}
|
||||
<DiscoveryRows
|
||||
recommendations={recommendations}
|
||||
similar={similar}
|
||||
libraryMap={libraryMap}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the headline director (or series creator) + the top-billed
|
||||
* actor from the credits and renders a FromSameRow for each. Skips
|
||||
* either row when there's nothing useful to render.
|
||||
*/
|
||||
function FromSameRowsCluster({
|
||||
itemType,
|
||||
tmdbId,
|
||||
cast,
|
||||
crew,
|
||||
libraryMap,
|
||||
}: {
|
||||
itemType: string
|
||||
tmdbId: string | null | undefined
|
||||
cast: unknown[]
|
||||
crew: unknown[]
|
||||
libraryMap: Map<string, { id: string; name: string; type: string }> | undefined
|
||||
}) {
|
||||
const kind: 'movie' | 'tv' = itemType === 'Series' ? 'tv' : 'movie'
|
||||
const crewList = crew as Array<{ id: number; name: string; job?: string; department?: string }>
|
||||
const castList = cast as Array<{ id: number; name: string; order?: number }>
|
||||
|
||||
const director = crewList.find(c => c.job === 'Director')
|
||||
// For series, "Director" is rarely meaningful (each episode has its
|
||||
// own); use the executive producer / creator chain instead.
|
||||
const creator = kind === 'tv'
|
||||
? crewList.find(c => c.job === 'Executive Producer' || c.job === 'Creator')
|
||||
: null
|
||||
const headlinePerson = director || creator
|
||||
const topActor = [...castList].sort((a, b) => (a.order ?? 999) - (b.order ?? 999))[0]
|
||||
|
||||
const excludeId = tmdbId ? Number(tmdbId) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
{headlinePerson && (
|
||||
<FromSameRow
|
||||
personId={headlinePerson.id}
|
||||
personName={headlinePerson.name}
|
||||
role={director ? 'director' : 'writer'}
|
||||
excludeTmdbId={excludeId}
|
||||
kind={kind}
|
||||
libraryMap={libraryMap}
|
||||
/>
|
||||
)}
|
||||
{topActor && (
|
||||
<FromSameRow
|
||||
personId={topActor.id}
|
||||
personName={topActor.name}
|
||||
role="actor"
|
||||
excludeTmdbId={excludeId}
|
||||
kind={kind}
|
||||
libraryMap={libraryMap}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user