mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: person details page
This commit is contained in:
@@ -904,6 +904,8 @@ components:
|
||||
type: string
|
||||
character:
|
||||
type: string
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
CreditCrew:
|
||||
type: object
|
||||
properties:
|
||||
@@ -958,6 +960,8 @@ components:
|
||||
type: string
|
||||
job:
|
||||
type: string
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
|
7
server/interfaces/api/personInterfaces.ts
Normal file
7
server/interfaces/api/personInterfaces.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
||||
|
||||
export interface PersonCombinedCreditsResponse {
|
||||
id: number;
|
||||
cast: PersonCreditCast[];
|
||||
crew: PersonCreditCrew[];
|
||||
}
|
@@ -3,6 +3,7 @@ import {
|
||||
TmdbPersonCreditCrew,
|
||||
TmdbPersonDetail,
|
||||
} from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
|
||||
export interface PersonDetail {
|
||||
id: number;
|
||||
@@ -42,6 +43,7 @@ export interface PersonCredit {
|
||||
title: string;
|
||||
adult: boolean;
|
||||
releaseDate: string;
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export interface PersonCreditCast extends PersonCredit {
|
||||
@@ -76,7 +78,8 @@ export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({
|
||||
});
|
||||
|
||||
export const mapCastCredits = (
|
||||
cast: TmdbPersonCreditCast
|
||||
cast: TmdbPersonCreditCast,
|
||||
media?: Media
|
||||
): PersonCreditCast => ({
|
||||
id: cast.id,
|
||||
originalLanguage: cast.original_language,
|
||||
@@ -100,10 +103,12 @@ export const mapCastCredits = (
|
||||
adult: cast.adult,
|
||||
releaseDate: cast.release_date,
|
||||
character: cast.character,
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
export const mapCrewCredits = (
|
||||
crew: TmdbPersonCreditCrew
|
||||
crew: TmdbPersonCreditCrew,
|
||||
media?: Media
|
||||
): PersonCreditCrew => ({
|
||||
id: crew.id,
|
||||
originalLanguage: crew.original_language,
|
||||
@@ -128,4 +133,5 @@ export const mapCrewCredits = (
|
||||
releaseDate: crew.release_date,
|
||||
department: crew.department,
|
||||
job: crew.job,
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import logger from '../logger';
|
||||
import {
|
||||
mapCastCredits,
|
||||
@@ -32,9 +33,27 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const castMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.cast.map((result) => result.id)
|
||||
);
|
||||
|
||||
const crewMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.crew.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
cast: combinedCredits.cast.map((result) => mapCastCredits(result)),
|
||||
crew: combinedCredits.crew.map((result) => mapCrewCredits(result)),
|
||||
cast: combinedCredits.cast.map((result) =>
|
||||
mapCastCredits(
|
||||
result,
|
||||
castMedia.find((med) => med.tmdbId === result.id)
|
||||
)
|
||||
),
|
||||
crew: combinedCredits.crew.map((result) =>
|
||||
mapCrewCredits(
|
||||
result,
|
||||
crewMedia.find((med) => med.tmdbId === result.id)
|
||||
)
|
||||
),
|
||||
id: combinedCredits.id,
|
||||
});
|
||||
});
|
||||
|
@@ -527,6 +527,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
items={data?.credits.cast.slice(0, 20).map((person) => (
|
||||
<PersonCard
|
||||
key={`cast-item-${person.id}`}
|
||||
personId={person.id}
|
||||
name={person.name}
|
||||
subName={person.character}
|
||||
profilePath={person.profilePath}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
interface PersonCardProps {
|
||||
personId: number;
|
||||
name: string;
|
||||
subName?: string;
|
||||
profilePath?: string;
|
||||
@@ -8,50 +10,55 @@ interface PersonCardProps {
|
||||
}
|
||||
|
||||
const PersonCard: React.FC<PersonCardProps> = ({
|
||||
personId,
|
||||
name,
|
||||
subName,
|
||||
profilePath,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} bg-gray-600 rounded-lg text-white shadow-lg hover:bg-gray-500 transition ease-in-out duration-150 cursor-pointer`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath})`,
|
||||
}}
|
||||
className="rounded-full w-28 h-28 md:w-32 md:h-32 bg-cover bg-center mb-6"
|
||||
/>
|
||||
)}
|
||||
{!profilePath && (
|
||||
<svg
|
||||
className="w-28 h-28 md:w-32 md:h-32 mb-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<div className="whitespace-normal text-center">{name}</div>
|
||||
{subName && (
|
||||
<div className="whitespace-normal text-center text-sm text-gray-300">
|
||||
{subName}
|
||||
<Link href={`/person/${personId}`}>
|
||||
<a>
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} bg-gray-600 rounded-lg text-white shadow-lg hover:bg-gray-500 transition ease-in-out duration-150 cursor-pointer`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath})`,
|
||||
}}
|
||||
className="rounded-full w-28 h-28 md:w-32 md:h-32 bg-cover bg-center mb-6"
|
||||
/>
|
||||
)}
|
||||
{!profilePath && (
|
||||
<svg
|
||||
className="w-28 h-28 md:w-32 md:h-32 mb-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<div className="whitespace-normal text-center">{name}</div>
|
||||
{subName && (
|
||||
<div className="whitespace-normal text-center text-sm text-gray-300">
|
||||
{subName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
|
120
src/components/PersonDetails/index.tsx
Normal file
120
src/components/PersonDetails/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { PersonDetail } from '../../../server/models/Person';
|
||||
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
|
||||
import Error from '../../pages/_error';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import Slider from '../Slider';
|
||||
import TitleCard from '../TitleCard';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
|
||||
const messages = defineMessages({
|
||||
appearsin: 'Appears in',
|
||||
ascharacter: 'as {character}',
|
||||
nobiography: 'No biography available.',
|
||||
});
|
||||
|
||||
const PersonDetails: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const router = useRouter();
|
||||
const { data, error } = useSWR<PersonDetail>(
|
||||
`/api/v1/person/${router.query.personId}`
|
||||
);
|
||||
|
||||
const {
|
||||
data: combinedCredits,
|
||||
error: errorCombinedCredits,
|
||||
} = useSWR<PersonCombinedCreditsResponse>(
|
||||
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const sortedCast = combinedCredits?.cast.sort((a, b) => {
|
||||
const aDate =
|
||||
a.mediaType === 'movie'
|
||||
? a.releaseDate?.slice(0, 4) ?? 0
|
||||
: a.firstAirDate?.slice(0, 4) ?? 0;
|
||||
const bDate =
|
||||
b.mediaType === 'movie'
|
||||
? b.releaseDate?.slice(0, 4) ?? 0
|
||||
: b.firstAirDate?.slice(0, 4) ?? 0;
|
||||
if (aDate > bDate) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex mt-8 mb-8 flex-col md:flex-row items-center">
|
||||
{data.profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath})`,
|
||||
}}
|
||||
className="rounded-full w-36 h-36 md:w-44 md:h-44 bg-cover bg-center mb-6 md:mb-0 mr-0 md:mr-6 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="text-gray-300 text-center md:text-left">
|
||||
<h1 className="text-3xl md:text-4xl text-white mb-4">{data.name}</h1>
|
||||
<div>
|
||||
{data.biography
|
||||
? data.biography
|
||||
: intl.formatMessage(messages.nobiography)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<span>{intl.formatMessage(messages.appearsin)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
isEmpty={!sortedCast}
|
||||
isLoading={!combinedCredits && !errorCombinedCredits}
|
||||
sliderKey={`person-${data.id}-slider-cast`}
|
||||
items={sortedCast?.map((media) => {
|
||||
return (
|
||||
<div key={`slider-cast-item-${media.id}`} className="flex flex-col">
|
||||
<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}
|
||||
/>
|
||||
{media.character && (
|
||||
<div className="mt-2 text-gray-300 text-xs truncate w-36 sm:w-36 md:w-44 text-center">
|
||||
{intl.formatMessage(messages.ascharacter, {
|
||||
character: media.character,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonDetails;
|
@@ -478,6 +478,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
items={data?.credits.cast.slice(0, 20).map((person) => (
|
||||
<PersonCard
|
||||
key={`cast-item-${person.id}`}
|
||||
personId={person.id}
|
||||
name={person.name}
|
||||
subName={person.character}
|
||||
profilePath={person.profilePath}
|
||||
|
9
src/pages/person/[personId]/index.tsx
Normal file
9
src/pages/person/[personId]/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { NextPage } from 'next';
|
||||
import PersonDetails from '../../../components/PersonDetails';
|
||||
|
||||
const MoviePage: NextPage = () => {
|
||||
return <PersonDetails />;
|
||||
};
|
||||
|
||||
export default MoviePage;
|
Reference in New Issue
Block a user