130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { motion } from 'framer-motion'
|
|
import { useTmdbPerson } from '../../hooks/use-tmdb'
|
|
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
|
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
|
import { getTmdbImageUrl } from '../../api/tmdb'
|
|
import ContentRow from './ContentRow'
|
|
|
|
interface Props {
|
|
/** TMDB person id. */
|
|
personId: number
|
|
/** Display name; pre-known from the spotlight aggregation. */
|
|
name: string
|
|
/** "Director" or "Actor" - drives the credit filter. */
|
|
role: 'director' | 'actor'
|
|
profilePath?: string | null
|
|
/** How many of this person's titles the user has already watched. */
|
|
watchedCount: number
|
|
}
|
|
|
|
const COMMERCIAL_DEPARTMENTS = ['Acting', 'Directing']
|
|
|
|
/**
|
|
* Renders a personal-pick row sourced from a TMDB person's filmography.
|
|
*
|
|
* Strategy:
|
|
* - Director: pull from `combined_credits.crew` filtered to job="Director",
|
|
* sorted by release date descending.
|
|
* - Actor: pull from `combined_credits.cast`, dropping low-billed
|
|
* appearances (one-line cameos), sorted by popularity then date.
|
|
*
|
|
* In-library matches show the accent ring naturally via PosterCard's
|
|
* `_inLibrary` honoring; missing items render in the row beside them.
|
|
*/
|
|
export default function PersonSpotlightRow({ personId, name, role, profilePath, watchedCount }: Props) {
|
|
const person = useTmdbPerson(personId)
|
|
const libraryByTmdbId = useLibraryByTmdbId()
|
|
|
|
const items = useMemo(() => {
|
|
const credits = person.data?.combined_credits
|
|
if (!credits) return []
|
|
const list =
|
|
role === 'director'
|
|
? (credits.crew || []).filter(c => c.job === 'Director')
|
|
: (credits.cast || [])
|
|
// Drop trivia / archival appearances and ultra-deep cuts.
|
|
.filter(c => COMMERCIAL_DEPARTMENTS.includes((c as { department?: string }).department || 'Acting'))
|
|
|
|
// De-dupe by id (a director may have multiple crew credits on one film
|
|
// when they're also writer; a cast member may appear twice for episodic
|
|
// tv guest spots).
|
|
const seen = new Set<number>()
|
|
const unique = list.filter(c => {
|
|
if (seen.has(c.id)) return false
|
|
seen.add(c.id)
|
|
return true
|
|
})
|
|
|
|
// Sort: newest first, but with low-popularity entries pushed to the end.
|
|
const sorted = [...unique].sort((a, b) => {
|
|
const ap = a.popularity ?? 0
|
|
const bp = b.popularity ?? 0
|
|
// Bucket popularity so date dominates within "famous-enough" tiers.
|
|
const ab = ap >= 5 ? 0 : 1
|
|
const bb = bp >= 5 ? 0 : 1
|
|
if (ab !== bb) return ab - bb
|
|
const ad = (a.release_date || a.first_air_date || '').slice(0, 10)
|
|
const bd = (b.release_date || b.first_air_date || '').slice(0, 10)
|
|
return bd.localeCompare(ad)
|
|
})
|
|
|
|
return mapTmdbToJf(sorted.slice(0, 24), libraryByTmdbId.data)
|
|
}, [person.data, libraryByTmdbId.data, role])
|
|
|
|
if (items.length === 0) return null
|
|
|
|
const totalCount = items.length
|
|
const remaining = Math.max(0, totalCount - watchedCount)
|
|
|
|
return (
|
|
<section className="mb-10">
|
|
<div className="px-7 mb-3.5 flex items-end justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
{profilePath && (
|
|
<motion.img
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.3 }}
|
|
src={getTmdbImageUrl(profilePath, 'w185')}
|
|
alt=""
|
|
className="w-12 h-12 rounded-full object-cover ring-1 ring-border shrink-0"
|
|
/>
|
|
)}
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-0.5">
|
|
{role === 'director' ? 'Director spotlight' : 'Following the actor'}
|
|
</p>
|
|
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight leading-tight">
|
|
<PersonName personId={personId} name={name} />
|
|
</h2>
|
|
<p className="text-[12px] text-text-3 mt-0.5">
|
|
<span className="tabular-nums text-text-2 font-medium">{watchedCount}</span> watched
|
|
{' · '}
|
|
<span className="tabular-nums text-text-2 font-medium">{remaining}</span> remaining
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ContentRow
|
|
title=""
|
|
items={items}
|
|
layoutKey={`spotlight_${role}_${personId}`}
|
|
/>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function PersonName({ personId, name }: { personId: number; name: string }) {
|
|
const navigate = useNavigate()
|
|
return (
|
|
<button
|
|
onClick={() => navigate(`/person/${personId}`)}
|
|
className="hover:text-accent transition focus-ring rounded"
|
|
>
|
|
{name}
|
|
</button>
|
|
)
|
|
}
|