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 | 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 ( <>
{/* Versions selector when there's more than one source */} {sources.length > 1 && (
)} {/* Synopsis */} {overview && (

{overview}

{overviewSource && (

Source: {overviewSource}

)} {overview.length > 600 && ( )}
)} 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 && (
)} {/* 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') && (
)} {/* 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 && (
)} {/* Episode-specific extras (only on Episode pages) */} {itemType === 'Episode' && prefs.detail.show.episodeExtras && (
)} {/* People row: Cast + Crew side-by-side */} {(cast.length > 0 || crew.length > 0) && (
{cast.length > 0 && (
)} {crew.length > 0 && (
)}
)} {/* "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) && (
{(itemType === 'Movie' || itemType === 'Series') && (
)} {watchProviders && (
)} {itemType === 'Series' && (
)}
)} {/* Videos */} {prefs.detail.show.videos && (videos?.length ?? 0) > 0 && (
)} {/* Trivia */} {prefs.detail.show.trivia && (wikiProduction?.extract || (keywords.length > 0 && wikiProduction)) && (
)} {/* Filming locations */} {prefs.detail.show.filmingLocations && (locationsData?.length ?? 0) > 0 && (
)} {/* 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'))) && (
{prefs.detail.show.awards && (awardsData?.length ?? 0) > 0 && (
)} {hasTechSpecs(item) && (
)} {item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.personal && (
)} {item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.diary && (
)} {keywords.length > 0 && (
{keywords.slice(0, 24).map(k => ( {k.name} ))}
)}
)} {/* Collection - the franchise / series strip */} {tmdbMovieData?.belongs_to_collection && (
)} {/* Reviews */} {reviews.length > 0 && (
)}
More like this

Titles by the same people on this project plus algorithmic picks you might want to queue up next.

{/* "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') && ( )} {/* Discovery rows - library matches first, then full lists */}
) } /** * 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 | 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 && ( )} {topActor && ( )} ) }