Files
jellybloom/src/components/detail/FromSameRow.tsx
T
2026-03-27 23:06:44 +02:00

109 lines
3.4 KiB
TypeScript

import { useMemo } from 'react'
import { useTmdbPerson } from '../../hooks/use-tmdb'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import ContentRow from '../ui/ContentRow'
import type { TmdbCombinedCreditCrew, TmdbCombinedCreditCast } from '../../api/tmdb'
type Role = 'director' | 'writer' | 'composer' | 'actor'
interface Props {
personId: number
personName: string
role: Role
/** TMDB id of the current item so it gets filtered out of the row. */
excludeTmdbId?: number | string | null
/** Filter the credit list to the same media kind as the current page. */
kind: 'movie' | 'tv'
libraryMap?: Map<string, { id: string; name: string; type: string }> | undefined
}
const ROLE_LABELS: Record<Role, string> = {
director: 'Director',
writer: 'Writer',
composer: 'Composer',
actor: 'Top-billed cast',
}
/**
* Row of other titles by a named person (director, writer, composer, or
* actor). Hides itself when there's too little material to be useful -
* one or two credits feels like a leak, four-plus reads as intentional.
*
* Filters to the same media kind (movie or tv) as the current detail
* page so a series page doesn't get drowned in director's movie work.
*/
export default function FromSameRow({
personId,
personName,
role,
excludeTmdbId,
kind,
libraryMap,
}: Props) {
const personQuery = useTmdbPerson(personId)
const items = useMemo(() => {
const credits = personQuery.data?.combined_credits
if (!credits) return []
const excludeStr = excludeTmdbId != null ? String(excludeTmdbId) : null
const source = role === 'actor' ? credits.cast : credits.crew
const matched = (source as Array<TmdbCombinedCreditCast | TmdbCombinedCreditCrew>).filter(c => {
if (c.media_type !== kind) return false
if (excludeStr && String(c.id) === excludeStr) return false
if (!c.poster_path) return false
if (role === 'actor') return true
const crew = c as TmdbCombinedCreditCrew
if (role === 'director') return crew.job === 'Director'
if (role === 'writer') return crew.department === 'Writing'
if (role === 'composer') {
return (
crew.job === 'Original Music Composer' ||
crew.job === 'Music' ||
crew.job === 'Composer'
)
}
return false
})
// De-dup by tmdb id (writers especially get credited multiple times
// on the same title under different jobs).
const seen = new Set<string>()
const deduped = matched.filter(c => {
const k = String(c.id)
if (seen.has(k)) return false
seen.add(k)
return true
})
return deduped
.sort((a, b) => (b.vote_average || 0) * (b.popularity || 0) - (a.vote_average || 0) * (a.popularity || 0))
.slice(0, 20)
}, [personQuery.data, role, excludeTmdbId, kind])
// Skip rows that would look anemic - fewer than 3 entries doesn't
// earn its keep on the page.
if (items.length < 3) return null
const mapped = mapTmdbToJf(
items.map(it => ({
...it,
adult: false,
// mapTmdbToJf reads media_type to pick kind, which is already set.
})),
libraryMap,
)
const title = role === 'actor' ? `More with ${personName}` : `From ${personName}`
const subtitle = `${ROLE_LABELS[role]} on this title - other ${kind === 'movie' ? 'films' : 'shows'} they've worked on`
return (
<ContentRow
title={title}
subtitle={subtitle}
items={mapped}
layoutKey={`from_same_${role}_${personId}`}
/>
)
}