460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
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,
|
|
TmdbCastMember,
|
|
} 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: TmdbCastMember[]
|
|
crew: TmdbCastMember[]
|
|
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 | null }[]
|
|
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} fallbackPeople={item.People} />
|
|
</Section>
|
|
)}
|
|
{crew.length > 0 && (
|
|
<Section label="Crew">
|
|
<CrewGrid crew={crew} />
|
|
<div className="mt-3">
|
|
<ComposerBlock crew={crew} />
|
|
</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}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|