mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(frontend/api): cast included with movie request and cast list on detail page
This commit is contained in:
@@ -99,6 +99,27 @@ interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
|
|||||||
results: TmdbTvResult[];
|
results: TmdbTvResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbCreditCast {
|
||||||
|
cast_id: number;
|
||||||
|
character: string;
|
||||||
|
credit_id: string;
|
||||||
|
gender?: number;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
profile_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbCreditCrew {
|
||||||
|
credit_id: string;
|
||||||
|
gender?: number;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
profile_path?: string;
|
||||||
|
job: string;
|
||||||
|
department: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TmdbMovieDetails {
|
export interface TmdbMovieDetails {
|
||||||
id: number;
|
id: number;
|
||||||
imdb_id?: string;
|
imdb_id?: string;
|
||||||
@@ -138,6 +159,10 @@ export interface TmdbMovieDetails {
|
|||||||
video: boolean;
|
video: boolean;
|
||||||
vote_average: number;
|
vote_average: number;
|
||||||
vote_count: number;
|
vote_count: number;
|
||||||
|
credits: {
|
||||||
|
cast: TmdbCreditCast[];
|
||||||
|
crew: TmdbCreditCrew[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbTvEpisodeDetails {
|
export interface TmdbTvEpisodeDetails {
|
||||||
@@ -258,7 +283,7 @@ class TheMovieDb {
|
|||||||
const response = await this.axios.get<TmdbMovieDetails>(
|
const response = await this.axios.get<TmdbMovieDetails>(
|
||||||
`/movie/${movieId}`,
|
`/movie/${movieId}`,
|
||||||
{
|
{
|
||||||
params: { language },
|
params: { language, append_to_response: 'credits' },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@@ -1,7 +1,32 @@
|
|||||||
import { TmdbMovieDetails } from '../api/themoviedb';
|
import {
|
||||||
|
TmdbMovieDetails,
|
||||||
|
TmdbCreditCast,
|
||||||
|
TmdbCreditCrew,
|
||||||
|
} from '../api/themoviedb';
|
||||||
import { MediaRequest } from '../entity/MediaRequest';
|
import { MediaRequest } from '../entity/MediaRequest';
|
||||||
import { ProductionCompany, Genre } from './common';
|
import { ProductionCompany, Genre } from './common';
|
||||||
|
|
||||||
|
export interface Cast {
|
||||||
|
id: number;
|
||||||
|
castId: number;
|
||||||
|
character: string;
|
||||||
|
creditId: string;
|
||||||
|
gender?: number;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
profilePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Crew {
|
||||||
|
id: number;
|
||||||
|
creditId: string;
|
||||||
|
department: string;
|
||||||
|
gender?: number;
|
||||||
|
job: string;
|
||||||
|
name: string;
|
||||||
|
profilePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MovieDetails {
|
export interface MovieDetails {
|
||||||
id: number;
|
id: number;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
@@ -33,9 +58,34 @@ export interface MovieDetails {
|
|||||||
video: boolean;
|
video: boolean;
|
||||||
voteAverage: number;
|
voteAverage: number;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
|
credits: {
|
||||||
|
cast: Cast[];
|
||||||
|
crew: Crew[];
|
||||||
|
};
|
||||||
request?: MediaRequest;
|
request?: MediaRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapCast = (person: TmdbCreditCast): Cast => ({
|
||||||
|
castId: person.cast_id,
|
||||||
|
character: person.character,
|
||||||
|
creditId: person.credit_id,
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
order: person.order,
|
||||||
|
gender: person.gender,
|
||||||
|
profilePath: person.profile_path,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapCrew = (person: TmdbCreditCrew): Crew => ({
|
||||||
|
creditId: person.credit_id,
|
||||||
|
department: person.department,
|
||||||
|
id: person.id,
|
||||||
|
job: person.job,
|
||||||
|
name: person.name,
|
||||||
|
gender: person.gender,
|
||||||
|
profilePath: person.profile_path,
|
||||||
|
});
|
||||||
|
|
||||||
export const mapMovieDetails = (
|
export const mapMovieDetails = (
|
||||||
movie: TmdbMovieDetails,
|
movie: TmdbMovieDetails,
|
||||||
request?: MediaRequest
|
request?: MediaRequest
|
||||||
@@ -69,5 +119,9 @@ export const mapMovieDetails = (
|
|||||||
posterPath: movie.poster_path,
|
posterPath: movie.poster_path,
|
||||||
runtime: movie.runtime,
|
runtime: movie.runtime,
|
||||||
tagline: movie.tagline,
|
tagline: movie.tagline,
|
||||||
|
credits: {
|
||||||
|
cast: movie.credits.cast.map(mapCast),
|
||||||
|
crew: movie.credits.crew.map(mapCrew),
|
||||||
|
},
|
||||||
request,
|
request,
|
||||||
});
|
});
|
||||||
|
@@ -419,6 +419,17 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
voteCount:
|
voteCount:
|
||||||
type: number
|
type: number
|
||||||
|
credits:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
cast:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Cast'
|
||||||
|
crew:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Crew'
|
||||||
request:
|
request:
|
||||||
$ref: '#/components/schemas/MediaRequest'
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
Episode:
|
Episode:
|
||||||
@@ -590,6 +601,48 @@ components:
|
|||||||
- mediaId
|
- mediaId
|
||||||
- mediaType
|
- mediaType
|
||||||
- status
|
- status
|
||||||
|
Cast:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 123
|
||||||
|
castId:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
character:
|
||||||
|
type: string
|
||||||
|
example: Some Character Name
|
||||||
|
creditId:
|
||||||
|
type: string
|
||||||
|
gender:
|
||||||
|
type: number
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: Some Persons Name
|
||||||
|
order:
|
||||||
|
type: number
|
||||||
|
profilePath:
|
||||||
|
type: string
|
||||||
|
Crew:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 123
|
||||||
|
creditId:
|
||||||
|
type: string
|
||||||
|
gender:
|
||||||
|
type: number
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: Some Persons Name
|
||||||
|
job:
|
||||||
|
type: string
|
||||||
|
department:
|
||||||
|
type: string
|
||||||
|
profilePath:
|
||||||
|
type: string
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
|
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '../../../../server/models/Search';
|
} from '../../../../server/models/Search';
|
||||||
import TitleCard from '../../TitleCard';
|
import TitleCard from '../../TitleCard';
|
||||||
import useVerticalScroll from '../../../hooks/useVerticalScroll';
|
import useVerticalScroll from '../../../hooks/useVerticalScroll';
|
||||||
|
import PersonCard from '../../PersonCard';
|
||||||
|
|
||||||
interface ListViewProps {
|
interface ListViewProps {
|
||||||
items?: (TvResult | MovieResult | PersonResult)[];
|
items?: (TvResult | MovieResult | PersonResult)[];
|
||||||
@@ -64,7 +65,9 @@ const ListView: React.FC<ListViewProps> = ({
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'person':
|
case 'person':
|
||||||
titleCard = <div>{title.name}</div>;
|
titleCard = (
|
||||||
|
<PersonCard name={title.name} profilePath={title.profilePath} />
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -11,6 +11,7 @@ import type { MovieResult } from '../../../server/models/Search';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Slider from '../Slider';
|
import Slider from '../Slider';
|
||||||
import TitleCard from '../TitleCard';
|
import TitleCard from '../TitleCard';
|
||||||
|
import PersonCard from '../PersonCard';
|
||||||
|
|
||||||
interface MovieDetailsProps {
|
interface MovieDetailsProps {
|
||||||
movie?: MovieDetailsType;
|
movie?: MovieDetailsType;
|
||||||
@@ -280,6 +281,42 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
||||||
|
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||||
|
<span>Cast</span>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 ml-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="cast"
|
||||||
|
isLoading={!data && !error}
|
||||||
|
isEmpty={false}
|
||||||
|
items={data?.credits.cast.slice(0, 20).map((person) => (
|
||||||
|
<PersonCard
|
||||||
|
key={`cast-item-${person.id}`}
|
||||||
|
name={person.name}
|
||||||
|
subName={person.character}
|
||||||
|
profilePath={person.profilePath}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Link
|
<Link
|
||||||
|
52
src/components/PersonCard/index.tsx
Normal file
52
src/components/PersonCard/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface PersonCardProps {
|
||||||
|
name: string;
|
||||||
|
subName?: string;
|
||||||
|
profilePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonCard: React.FC<PersonCardProps> = ({
|
||||||
|
name,
|
||||||
|
subName,
|
||||||
|
profilePath,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="relative w-36 sm:w-36 md:w-44 bg-cool-gray-600 rounded-lg text-white shadow-lg hover:bg-cool-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-cool-gray-300">
|
||||||
|
{subName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonCard;
|
@@ -99,7 +99,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
onOk={() => cancelRequest()}
|
onOk={() => cancelRequest()}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="titleCard outline-none"
|
className="titleCard outline-none cursor-default"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(//image.tmdb.org/t/p/w600_and_h900_bestv2${image})`,
|
backgroundImage: `url(//image.tmdb.org/t/p/w600_and_h900_bestv2${image})`,
|
||||||
}}
|
}}
|
||||||
@@ -114,7 +114,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 h-full w-full bottom-0 left-0 right-0 overflow-hidden shadow-md">
|
<div className="absolute top-0 h-full w-full bottom-0 left-0 right-0 overflow-hidden shadow-xl">
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-50 ${
|
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-50 ${
|
||||||
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
|
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
|
||||||
@@ -204,7 +204,9 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
|
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
|
||||||
<Link href={`/movie/${id}`}>
|
<Link
|
||||||
|
href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
|
||||||
|
>
|
||||||
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm mr-1 hover:bg-indigo-400 focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition ease-in-out duration-150">
|
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm mr-1 hover:bg-indigo-400 focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition ease-in-out duration-150">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 mx-auto"
|
className="w-4 mx-auto"
|
||||||
|
@@ -1,13 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { NextPage } from 'next';
|
import { NextPage } from 'next';
|
||||||
import PlexLoginButton from '../components/PlexLoginButton';
|
import PersonCard from '../components/PersonCard';
|
||||||
|
|
||||||
const PlexText: NextPage = () => {
|
const PlexText: NextPage = () => {
|
||||||
const [authToken, setAuthToken] = useState<string>('');
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PlexLoginButton onAuthToken={(authToken) => setAuthToken(authToken)} />
|
<PersonCard />
|
||||||
<div className="mt-4">Auth Token: {authToken}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user