diff --git a/src/components/detail/AwardsBlock.tsx b/src/components/detail/AwardsBlock.tsx new file mode 100644 index 0000000..92c7f79 --- /dev/null +++ b/src/components/detail/AwardsBlock.tsx @@ -0,0 +1,95 @@ +import { useMemo, useState } from 'react' +import { motion } from 'framer-motion' +import type { WikidataAward } from '../../api/wikidata' + +interface Props { + awards: WikidataAward[] | null | undefined +} + +const PRIORITY_KEYWORDS = [ + 'Academy Award', + 'Oscar', + 'Golden Globe', + 'BAFTA', + 'Emmy', + 'Cannes', + 'Palme', + 'Berlin', + 'Sundance', + 'Venice', + 'Critics', + 'SAG', + 'Hugo', + 'Nebula', + 'Saturn', + 'MTV', +] + +/** + * Compact award grid pulled from Wikidata P166. We surface the most + * recognised prizes first (Oscars, Globes, BAFTAs, Emmys, etc.), then + * dedupe and cap at a sensible count. A "Show all" toggle reveals the + * remainder when there are many. + */ +export default function AwardsBlock({ awards }: Props) { + const [expanded, setExpanded] = useState(false) + + const ordered = useMemo(() => { + if (!awards) return [] + const seen = new Set() + const unique: WikidataAward[] = [] + for (const a of awards) { + const key = a.label + if (seen.has(key)) continue + seen.add(key) + unique.push(a) + } + return unique.sort((a, b) => priority(a) - priority(b)) + }, [awards]) + + if (ordered.length === 0) return null + const visible = expanded ? ordered : ordered.slice(0, 12) + + return ( +
+
+ {visible.map((a, i) => ( + + + + + {a.label} + {a.point_in_time && ( + + {a.point_in_time.slice(0, 4)} + + )} + + ))} +
+ {ordered.length > 12 && ( + + )} +
+ ) +} + +function priority(a: WikidataAward): number { + const label = a.label || '' + for (let i = 0; i < PRIORITY_KEYWORDS.length; i++) { + if (label.includes(PRIORITY_KEYWORDS[i])) return i + } + return PRIORITY_KEYWORDS.length +} diff --git a/src/components/detail/CastList.tsx b/src/components/detail/CastList.tsx new file mode 100644 index 0000000..16eef0c --- /dev/null +++ b/src/components/detail/CastList.tsx @@ -0,0 +1,63 @@ +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import HorizontalScroller from '../ui/HorizontalScroller' +import { getTmdbImageUrl } from '../../api/tmdb' + +interface FallbackPerson { + Id?: string | null + Name?: string | null + Role?: string | null + PrimaryImageTag?: string | null + Type?: string | null +} + +interface Props { + cast: any[] + fallbackPeople?: FallbackPerson[] | null +} + +export default function CastList({ cast, fallbackPeople }: Props) { + const navigate = useNavigate() + const list = cast.length ? cast : (fallbackPeople || []).filter((p: any) => p.Type === 'Actor') + + return ( +
+ + {list.map((c: any, i: number) => ( + c.id && navigate(`/person/${c.id}`)} + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: Math.min(i * 0.025, 0.4) }} + whileHover={{ y: -2 }} + className="flex flex-col items-center text-center shrink-0 w-[88px] group focus-ring rounded-lg p-1" + > +
+ {c.profile_path ? ( + {c.name} + ) : ( +
+ {(c.name || c.Name || '?')[0]} +
+ )} +
+ + {c.name || c.Name} + + {(c.character || c.Role) && ( + + {c.character || c.Role} + + )} +
+ ))} +
+
+ ) +} diff --git a/src/components/detail/CastMenu.tsx b/src/components/detail/CastMenu.tsx new file mode 100644 index 0000000..97a4554 --- /dev/null +++ b/src/components/detail/CastMenu.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { MonitorPlay, Tv, Loader2 } from '../../lib/icons' +import { listCastTargets, sendToSession, type CastTarget } from '../../lib/cast' +import { toast } from '../../stores/toast-store' + +interface Props { + itemId: string + mediaType?: string +} + +/** + * "Play on..." picker. Pops a small list of other Jellyfin sessions that + * advertise SupportsRemoteControl, filtered by what they can play. Click + * a target to fire `Sessions/{id}/Playing` and hand the item off. + */ +export default function CastMenu({ itemId, mediaType }: Props) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [targets, setTargets] = useState([]) + const [sendingTo, setSendingTo] = useState(null) + const wrapRef = useRef(null) + + useEffect(() => { + if (!open) return + function onDocClick(e: MouseEvent) { + if (!wrapRef.current) return + if (e.target instanceof Node && !wrapRef.current.contains(e.target)) { + setOpen(false) + } + } + function onEsc(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onDocClick) + document.addEventListener('keydown', onEsc) + return () => { + document.removeEventListener('mousedown', onDocClick) + document.removeEventListener('keydown', onEsc) + } + }, [open]) + + useEffect(() => { + if (!open) return + let cancelled = false + setLoading(true) + listCastTargets(mediaType) + .then(t => { + if (!cancelled) setTargets(t) + }) + .catch(() => { + if (!cancelled) setTargets([]) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, mediaType]) + + async function castTo(target: CastTarget) { + setSendingTo(target.sessionId) + try { + await sendToSession(target.sessionId, itemId) + toast(`Sent to ${target.deviceName}`, 'success') + setOpen(false) + } catch { + toast('Could not send to that device', 'error') + } finally { + setSendingTo(null) + } + } + + return ( +
+ + + + {open && ( + +
+

+ Play on +

+
+
+ {loading && ( +
+ + Looking for devices... +
+ )} + {!loading && targets.length === 0 && ( +

+ No other devices are signed in to this server right now. +

+ )} + {!loading && + targets.map(t => { + const isSending = sendingTo === t.sessionId + return ( + + ) + })} +
+
+ )} +
+
+ ) +} diff --git a/src/components/detail/CollectionStrip.tsx b/src/components/detail/CollectionStrip.tsx new file mode 100644 index 0000000..b37f28c --- /dev/null +++ b/src/components/detail/CollectionStrip.tsx @@ -0,0 +1,144 @@ +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { Boxes, ChevronRight, Check } from '../../lib/icons' +import { getTmdbImageUrl, type TmdbCollectionRef, type TmdbCollection } from '../../api/tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' + +interface Props { + collectionRef?: TmdbCollectionRef | null + collection?: TmdbCollection | null + currentMovieId?: number | null +} + +export default function CollectionStrip({ collectionRef, collection, currentMovieId }: Props) { + const navigate = useNavigate() + const { data: libraryMap } = useLibraryByTmdbId() + const ref = collection || collectionRef + if (!ref) return null + + // Sort by release date ascending. Items with no release date (or an + // unparseable one) sort to the END so unannounced sequels don't hijack + // the #1 slot just because their date field is empty. + const parts = (collection?.parts || []).slice().sort((a, b) => { + const ta = a.release_date ? Date.parse(a.release_date) : NaN + const tb = b.release_date ? Date.parse(b.release_date) : NaN + const da = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY + const db = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY + return da - db + }) + const inLibraryCount = libraryMap + ? parts.reduce((acc, p) => acc + (libraryMap.has(String(p.id)) ? 1 : 0), 0) + : 0 + + return ( +
+ {/* Backdrop */} + {ref.backdrop_path && ( + <> +
+
+
+ + )} + {!ref.backdrop_path &&
} + +
+
+ + {parts.length > 0 && libraryMap && ( + + {inLibraryCount} + of + {parts.length} + in your library · watch in release order + + )} +
+ + {parts.length > 0 ? ( +
+ {parts.map((m, i) => { + const isCurrent = currentMovieId != null && m.id === currentMovieId + const inLibrary = libraryMap?.has(String(m.id)) ?? false + return ( + !isCurrent && navigate(`/item/tmdb-${m.id}`)} + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }} + whileHover={{ y: -3 }} + className={`shrink-0 w-[110px] focus-ring rounded-md text-left ${isCurrent ? 'cursor-default' : ''}`} + > +
+ {m.poster_path ? ( + {m.title} + ) : ( +
+ {m.title?.[0]} +
+ )} + + {i + 1} + + {inLibrary && !isCurrent && ( + + + + )} + {!inLibrary && !isCurrent && libraryMap && ( + + Missing + + )} + {isCurrent && ( +
+ + Current + +
+ )} +
+

{m.title}

+ {m.release_date && ( +

{m.release_date.slice(0, 4)}

+ )} +
+ ) + })} +
+ ) : ( +

Loading collection...

+ )} +
+
+ ) +} diff --git a/src/components/detail/ComposerBlock.tsx b/src/components/detail/ComposerBlock.tsx new file mode 100644 index 0000000..c981aa2 --- /dev/null +++ b/src/components/detail/ComposerBlock.tsx @@ -0,0 +1,51 @@ +import { useNavigate } from 'react-router-dom' +import type { TmdbCastMember } from '../../api/tmdb' + +interface Props { + crew: TmdbCastMember[] | null | undefined +} + +const COMPOSER_JOBS = new Set([ + 'Original Music Composer', + 'Music', + 'Composer', + 'Theme Song Performance', +]) + +/** + * "Theme by ..." line surfaced from TMDB credits.crew. Surfaces the + * composer(s) without burying them in the dense crew strip. Renders + * nothing when there's no composer credit. + */ +export default function ComposerBlock({ crew }: Props) { + const navigate = useNavigate() + if (!crew || crew.length === 0) return null + + const seen = new Set() + const composers: TmdbCastMember[] = [] + for (const c of crew) { + if (!c.job || !COMPOSER_JOBS.has(c.job)) continue + if (seen.has(c.id)) continue + seen.add(c.id) + composers.push(c) + if (composers.length >= 3) break + } + if (composers.length === 0) return null + + return ( +

+ Theme by + {composers.map((c, i) => ( + + + {i < composers.length - 1 && ·} + + ))} +

+ ) +} diff --git a/src/components/detail/CrewGrid.tsx b/src/components/detail/CrewGrid.tsx new file mode 100644 index 0000000..35feb0e --- /dev/null +++ b/src/components/detail/CrewGrid.tsx @@ -0,0 +1,75 @@ +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { getTmdbImageUrl, type TmdbCastMember } from '../../api/tmdb' +import HorizontalScroller from '../ui/HorizontalScroller' + +interface Props { + crew: TmdbCastMember[] + limit?: number +} + +const DEPT_ORDER = ['Directing', 'Writing', 'Production', 'Camera', 'Editing', 'Sound', 'Visual Effects', 'Art'] + +export default function CrewGrid({ crew, limit = 14 }: Props) { + if (!crew?.length) return null + + // De-duplicate by person id+job and prioritise high-impact departments + const seen = new Set() + const top: TmdbCastMember[] = [] + for (const dept of DEPT_ORDER) { + for (const c of crew) { + if (c.department !== dept) continue + const key = `${c.id}-${c.job}` + if (seen.has(key)) continue + seen.add(key) + top.push(c) + if (top.length >= limit) break + } + if (top.length >= limit) break + } + + if (!top.length) return null + + return ( +
+ + {top.map((c, i) => ( + + ))} + +
+ ) +} + +function CrewMember({ member, index }: { member: TmdbCastMember; index: number }) { + const navigate = useNavigate() + return ( + member.id && navigate(`/person/${member.id}`)} + className="flex flex-col items-center text-center shrink-0 w-[88px] group focus-ring rounded-lg p-1" + > +
+ {member.profile_path ? ( + {member.name} + ) : ( +
+ {member.name[0]} +
+ )} +
+ {member.name} + {member.job && ( + {member.job} + )} +
+ ) +} diff --git a/src/components/detail/DetailHero.tsx b/src/components/detail/DetailHero.tsx new file mode 100644 index 0000000..b4b275c --- /dev/null +++ b/src/components/detail/DetailHero.tsx @@ -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 + refresh: ReturnType +} + +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 ( +
+ {backdropUrl && ( + + )} +
+
+
+ +
+ {/* Left column: type pill + poster */} + + {itemType && } + {posterUrl && ( + { (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)]" + /> + )} + + + + {/* Type pill (small screens only - shown above poster on md+) */} +
+ {itemType && } + +
+ + {itemType === 'Episode' && item.SeriesId && ( + + )} + + {logoUrl ? ( +
+ {title} { (e.target as HTMLImageElement).style.display = 'none' }} + /> +
+ ) : ( +

+ {title} +

+ )} + + {taglineText && ( +

+ "{taglineText}" +

+ )} + +
+ {year && {year}} + {year && (rating || runtime) && } + {rating && ( + + {rating} + + )} + {rating && runtime && } + {runtime && ( + + {runtime} + + )} + {/* Ratings shown on the right-side card (and here only on small screens for accessibility) */} + + {showTmdbRatings && tmdbData?.vote_average != null && tmdbData.vote_average > 0 && ( + <> + + + + {tmdbData.vote_average.toFixed(1)} + TMDB + + + )} + {item.CommunityRating != null && item.CommunityRating > 0 && ( + <> + + + + {item.CommunityRating.toFixed(1)} + Jellyfin + + + )} + + {cinemetaData?.imdbRating && Number(cinemetaData.imdbRating) > 0 && ( + <> + + + + {Number(cinemetaData.imdbRating).toFixed(1)} + IMDB + + + )} +
+ + {genres.length > 0 && ( +
+ {genres.slice(0, 5).map(g => ( + + {g} + + ))} +
+ )} + +
+ + + {resumeTime && ( + + )} + + {trailer && ( + + )} + + {item.Id && (item.Type === 'Movie' || item.Type === 'Series') && ( + + )} + + {tmdbId && (item.Type === 'Movie' || item.Type === 'Series') && ( + + )} + + + + + + + + { + 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') + }} + > + + + {item.Id && } + { + 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' + } + > + + +
+ + + + {progress != null && progress > 0 && ( +
+
+ Watch progress + {Math.round(progress)}% +
+
+ +
+
+ )} +
+ + + + refresh.mutate({ + itemId: item.Id!, + replaceAllMetadata: false, + replaceAllImages: false, + }) + : undefined + } + isRescanning={refresh.isPending} + /> + +
+ +
+
+ ) +} + +function TypePill({ itemType }: { itemType: string }) { + return ( + + {itemType === 'Series' || itemType === 'Episode' ? ( + + ) : itemType === 'MusicAlbum' || itemType === 'MusicArtist' ? ( + + ) : ( + + )} + {itemType === 'Series' ? 'Series' : itemType === 'Movie' ? 'Movie' : itemType} + + ) +} + +function EpisodeBreadcrumb({ item, navigate }: { item: BaseItemDto; navigate: NavigateFunction }) { + return ( + + ) +} + +function Dot() { + return · +} + +function IconButton({ + children, + active, + ...props +}: React.ButtonHTMLAttributes & { active?: boolean }) { + return ( + + ) +} diff --git a/src/components/detail/DetailMainSections.tsx b/src/components/detail/DetailMainSections.tsx new file mode 100644 index 0000000..eb800c7 --- /dev/null +++ b/src/components/detail/DetailMainSections.tsx @@ -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 | 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 ( + <> +
+ {/* 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 && ( + + )} + + ) +} diff --git a/src/components/detail/DetailSectionTabs.tsx b/src/components/detail/DetailSectionTabs.tsx new file mode 100644 index 0000000..a7db776 --- /dev/null +++ b/src/components/detail/DetailSectionTabs.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import { motion } from 'framer-motion' + +export interface SectionTab { + id: string + label: string +} + +interface Props { + /** Element ids the user can jump to, in display order. The component + * filters out ones that don't actually exist in the DOM at render + * time so the tab row mirrors what's on the page. */ + tabs: SectionTab[] +} + +/** + * Compact table-of-contents strip rendered just below the hero. Smooth- + * scrolls to the targeted section on click and keeps the active tab + * synced with the viewport via IntersectionObserver - useful on long + * series pages where the user might want to jump from Overview straight + * to Cast or More-like-this without scrolling. + * + * The tabs are NOT sticky - that role is owned by `DetailStickyBar` so + * the page doesn't end up with two competing top-anchored chromes. + */ +export default function DetailSectionTabs({ tabs }: Props) { + const [active, setActive] = useState(null) + const [available, setAvailable] = useState([]) + + // Filter to tabs whose target element actually exists in the DOM. + // Sections are conditionally rendered, so listing them all from the + // parent would surface dead links for sections that hid themselves. + useEffect(() => { + const present = tabs.filter(t => document.getElementById(t.id)) + setAvailable(present) + }, [tabs]) + + // Spy on each section so the active tab matches what's mostly in view. + useEffect(() => { + if (available.length === 0) return + const root = document.querySelector('main.content-scroll') as HTMLElement | null + const observer = new IntersectionObserver( + entries => { + // Pick the highest entry that's in view - the one closest to the + // top of the viewport from the user's perspective. + const inView = entries + .filter(e => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) + if (inView.length > 0) { + setActive(inView[0].target.id) + } + }, + { + root, + // Activate when a section's top is near the top of the viewport, + // not when it just barely enters from below. The negative bottom + // margin makes the active zone the upper third of the viewport. + rootMargin: '-12% 0px -65% 0px', + threshold: 0, + }, + ) + for (const t of available) { + const el = document.getElementById(t.id) + if (el) observer.observe(el) + } + return () => observer.disconnect() + }, [available]) + + function scrollTo(id: string) { + const el = document.getElementById(id) + if (!el) return + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + setActive(id) + } + + if (available.length < 2) return null + + return ( + + ) +} diff --git a/src/components/detail/DetailSkeleton.tsx b/src/components/detail/DetailSkeleton.tsx new file mode 100644 index 0000000..bb301e3 --- /dev/null +++ b/src/components/detail/DetailSkeleton.tsx @@ -0,0 +1,30 @@ +export default function DetailSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/components/detail/DetailStickyBar.tsx b/src/components/detail/DetailStickyBar.tsx new file mode 100644 index 0000000..a1bbfe6 --- /dev/null +++ b/src/components/detail/DetailStickyBar.tsx @@ -0,0 +1,147 @@ +import { motion, AnimatePresence } from 'framer-motion' +import { useNavigate } from 'react-router-dom' +import { Play, Heart, Share2 } from '../../lib/icons' +import { buildShareUrl, copyToClipboard } from '../../lib/share' +import { toast } from '../../stores/toast-store' +import DetailSectionTabs, { type SectionTab } from './DetailSectionTabs' + +interface Props { + /** Controlled visibility - parent decides whether the chrome is shown. + * Pass `usePastSentinel(sentinelRef)` for the standard behaviour. */ + visible: boolean + title: string + posterUrl?: string | null + logoUrl?: string | null + progress?: number | null + isFavorite?: boolean | null + playUrl: string + resumeUrl: string + tmdbId?: string | number | null + imdbId?: string | null + itemType?: string + onFavoriteToggle?: () => void + /** Section tabs to render below the action row. Pass an empty array + * to hide the tab strip; pass a list to enable section nav. */ + tabs?: SectionTab[] +} + +/** + * Sticky top chrome for the detail page. Owns the slim action row + * (logo / title + Share / Favorite / Play) AND the section tabs + * underneath. Both appear together once the user has scrolled past + * the hero, animated in as a unit so the chrome doesn't look like two + * separate things flickering on screen. + * + * Visibility is fully controlled - the parent owns the sentinel + the + * `usePastSentinel` hook. This component just renders. + */ +export default function DetailStickyBar({ + visible, + title, + posterUrl, + logoUrl, + progress, + isFavorite, + playUrl, + resumeUrl, + tmdbId, + imdbId, + itemType, + onFavoriteToggle, + tabs, +}: Props) { + const navigate = useNavigate() + const hasProgress = typeof progress === 'number' && progress > 0 + const hasTabs = !!tabs && tabs.length > 1 + + async function onShare() { + const url = buildShareUrl({ + tmdbId, + imdbId, + 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') + } + + return ( +
+ + {visible && ( + +
+ {logoUrl ? ( + { (e.target as HTMLImageElement).style.display = 'none' }} + /> + ) : posterUrl ? ( + + ) : null} + + + {logoUrl ? '' : title} + + +
+ + {onFavoriteToggle && ( + + )} + {playUrl && ( + + )} +
+
+ + {hasTabs && ( +
+ +
+ )} +
+ )} +
+
+ ) +} diff --git a/src/components/detail/DiarySection.tsx b/src/components/detail/DiarySection.tsx new file mode 100644 index 0000000..aa019f6 --- /dev/null +++ b/src/components/detail/DiarySection.tsx @@ -0,0 +1,230 @@ +import { useMemo, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Plus, Star, Trash2, X } from '../../lib/icons' +import { useDiary, type DiaryEntry } from '../../stores/diary-store' + +interface Props { + itemId: string + itemName: string +} + +const QUICK_EMOJI = ['🎬', '🍿', '😢', '😱', '🤯', '😴', '❤️', '🤩', '🤔', '😂', '👀', '🔥'] + +/** + * Per-item diary log. Shows chronological entries for this item with + * optional rating + emoji + note, plus a "Log a watch" button that + * opens an inline editor for a new entry. + * + * Editing works in-place with a small drawer below each entry. + */ +export default function DiarySection({ itemId, itemName }: Props) { + // Select the whole array; filter + sort in render via useMemo. + // A selector that returns .filter()/.sort() output creates a new + // reference each call and trips useSyncExternalStore's loop guard. + const allEntries = useDiary(s => s.entries) + const add = useDiary(s => s.add) + const remove = useDiary(s => s.remove) + const [composing, setComposing] = useState(false) + const sorted = useMemo( + () => + allEntries + .filter(e => e.itemId === itemId) + .sort((a, b) => b.watchedAt.localeCompare(a.watchedAt)), + [allEntries, itemId], + ) + + function startCompose() { + setComposing(true) + } + + function commitNew(payload: { rating?: number; note?: string; emoji?: string; rewatch?: boolean }) { + add({ itemId, itemName, ...payload }) + setComposing(false) + } + + return ( +
+
+

+ {sorted.length === 0 + ? "You haven't logged a watch yet for this item." + : `${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'}`} +

+ +
+ + + {composing && ( + + setComposing(false)} onSave={commitNew} /> + + )} + + + {sorted.length > 0 && ( +
    + {sorted.map(entry => ( + remove(entry.id)} /> + ))} +
+ )} +
+ ) +} + +function Composer({ + onCancel, + onSave, +}: { + onCancel: () => void + onSave: (payload: { rating?: number; note?: string; emoji?: string; rewatch?: boolean }) => void +}) { + const [rating, setRating] = useState(0) + const [note, setNote] = useState('') + const [emoji, setEmoji] = useState(undefined) + const [rewatch, setRewatch] = useState(false) + return ( +
+
+ {QUICK_EMOJI.map(g => ( + + ))} +
+ +
+ + Rating + + {Array.from({ length: 10 }).map((_, i) => { + const n = i + 1 + const on = n <= rating + return ( + + ) + })} + {rating > 0 && ( + {rating}/10 + )} +
+ +