Files
channels-seerr/src/components/PersonDetails/index.tsx
Gauthier 4e48fdf2cb fix: rewrite avatarproxy and CachedImage (#1016)
* fix: rewrite avatarproxy and CachedImage

Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or
filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby
images and TMDB images.

fix #1012, #1013

* fix: resolve CodeQL error

* fix: resolve CodeQL error

* fix: resolve review comments

* fix: resolve review comment

* fix: resolve CodeQL error

* fix: update imageproxy path
2024-10-17 21:24:15 +08:00

284 lines
9.0 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 defineMessages from '@app/utils/defineMessages';
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 { useIntl } from 'react-intl';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
const messages = defineMessages('components.PersonDetails', {
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',
timeZone: 'UTC',
}),
deathdate: intl.formatDate(data.deathday, {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}),
})
);
} else {
personAttributes.push(
intl.formatMessage(messages.birthdate, {
birthdate: intl.formatDate(data.birthday, {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}),
})
);
}
}
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
key={media.id}
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
key={media.id}
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
type="tmdb"
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
/>
</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;