Files
jellybloom/src/components/detail/DetailMainSections.tsx
T
2026-05-01 08:30:36 +03:00

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}
/>
)}
</>
)
}