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

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