Files
jellyseerr/src/components/PersonDetails/index.tsx

277 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Ellipsis from '@app/assets/ellipsis.svg';
import CachedImage from '@app/components/Common/CachedImage';
import ImageFader from '@app/components/Common/ImageFader';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import TitleCard from '@app/components/TitleCard';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces';
import type { PersonDetails as PersonDetailsType } from '@server/models/Person';
import { groupBy } from 'lodash';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
const messages = defineMessages({
birthdate: 'Born {birthdate}',
lifespan: '{birthdate} {deathdate}',
alsoknownas: 'Also Known As: {names}',
appearsin: 'Appearances',
crewmember: 'Crew',
ascharacter: 'as {character}',
});
const PersonDetails = () => {
const intl = useIntl();
const router = useRouter();
const { data, error } = useSWR<PersonDetailsType>(
`/api/v1/person/${router.query.personId}`
);
const [showBio, setShowBio] = useState(false);
const { data: combinedCredits, error: errorCombinedCredits } =
useSWR<PersonCombinedCreditsResponse>(
`/api/v1/person/${router.query.personId}/combined_credits`
);
const sortedCast = useMemo(() => {
const grouped = groupBy(combinedCredits?.cast ?? [], 'id');
const reduced = Object.values(grouped).map((objs) => ({
...objs[0],
character: objs.map((pos) => pos.character).join(', '),
}));
return reduced.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
}, [combinedCredits]);
const sortedCrew = useMemo(() => {
const grouped = groupBy(combinedCredits?.crew ?? [], 'id');
const reduced = Object.values(grouped).map((objs) => ({
...objs[0],
job: objs.map((pos) => pos.job).join(', '),
}));
return reduced.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
}, [combinedCredits]);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
const personAttributes: string[] = [];
if (data.birthday) {
if (data.deathday) {
personAttributes.push(
intl.formatMessage(messages.lifespan, {
birthdate: intl.formatDate(data.birthday, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
deathdate: intl.formatDate(data.deathday, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})
);
} else {
personAttributes.push(
intl.formatMessage(messages.birthdate, {
birthdate: intl.formatDate(data.birthday, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})
);
}
}
if (data.placeOfBirth) {
personAttributes.push(data.placeOfBirth);
}
const isLoading = !combinedCredits && !errorCombinedCredits;
const cast = (sortedCast ?? []).length > 0 && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
<ul className="cards-vertical">
{sortedCast?.map((media, index) => {
return (
<li key={`list-cast-item-${media.id}-${index}`}>
<TitleCard
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.character && (
<div className="mt-2 w-full truncate text-center text-xs text-gray-300">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
</div>
)}
</li>
);
})}
</ul>
</>
);
const crew = (sortedCrew ?? []).length > 0 && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
</div>
<ul className="cards-vertical">
{sortedCrew?.map((media, index) => {
return (
<li key={`list-crew-item-${media.id}-${index}`}>
<TitleCard
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.job && (
<div className="mt-2 w-full truncate text-center text-xs text-gray-300">
{media.job}
</div>
)}
</li>
);
})}
</ul>
</>
);
return (
<>
<PageTitle title={data.name} />
{(sortedCrew || sortedCast) && (
<div className="absolute top-0 left-0 right-0 z-0 h-96">
<ImageFader
isDarker
backgroundImages={[...(sortedCast ?? []), ...(sortedCrew ?? [])]
.filter((media) => media.backdropPath)
.map(
(media) =>
`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
)
.slice(0, 6)}
/>
</div>
)}
<div
className={`relative z-10 mt-4 mb-8 flex flex-col items-center lg:flex-row ${
data.biography ? 'lg:items-start' : ''
}`}
>
{data.profilePath && (
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
<CachedImage
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
alt=""
layout="fill"
objectFit="cover"
/>
</div>
)}
<div className="text-center text-gray-300 lg:text-left">
<h1 className="text-3xl text-white lg:text-4xl">{data.name}</h1>
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
<div>{personAttributes.join(' | ')}</div>
{(data.alsoKnownAs ?? []).length > 0 && (
<div>
{intl.formatMessage(messages.alsoknownas, {
names: (data.alsoKnownAs ?? []).reduce((prev, curr) =>
intl.formatMessage(globalMessages.delimitedlist, {
a: prev,
b: curr,
})
),
})}
</div>
)}
</div>
{data.biography && (
<div className="relative text-left">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className="group outline-none ring-0"
onClick={() => setShowBio((show) => !show)}
role="button"
tabIndex={-1}
>
<TruncateMarkup
lines={showBio ? 200 : 6}
ellipsis={
<Ellipsis className="relative -top-0.5 ml-2 inline-block opacity-70 transition duration-300 group-hover:opacity-100" />
}
>
<p className="pt-2 text-sm lg:text-base">{data.biography}</p>
</TruncateMarkup>
</div>
</div>
)}
</div>
</div>
{data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]}
{isLoading && <LoadingSpinner />}
</>
);
};
export default PersonDetails;