feat: add ActorHero component and actor details page; implement API integration for fetching actor information and enhance MovieCast with links to actor profiles
This commit is contained in:
parent
7a7bc2575b
commit
cf7ec070fd
|
|
@ -0,0 +1,18 @@
|
|||
import { ActorHero } from "@/components/molecules/ActorHero";
|
||||
import { TMDB } from "@/lib/tmdb";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const actorId = Number((await params).id);
|
||||
|
||||
const personDetails = await TMDB.getPersonDetails(actorId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
<ActorHero personDetails={personDetails} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
"use client";
|
||||
|
||||
import { BackButton } from "@/components/atoms/BackButton";
|
||||
import { PersonDetailsRich } from "@/lib/tmdb/types";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
FaCalendarAlt,
|
||||
FaMapMarkerAlt,
|
||||
FaStar,
|
||||
FaTheaterMasks,
|
||||
FaImdb,
|
||||
FaFacebook,
|
||||
FaInstagram,
|
||||
FaTwitter,
|
||||
FaYoutube,
|
||||
FaTiktok,
|
||||
} from "react-icons/fa";
|
||||
|
||||
type Props = {
|
||||
personDetails: PersonDetailsRich;
|
||||
};
|
||||
|
||||
export const ActorHero: FC<Props> = ({ personDetails }) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("pl-PL", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const calculateAge = (birthday: string, deathday?: string | null) => {
|
||||
const birth = new Date(birthday);
|
||||
const end = deathday ? new Date(deathday) : new Date();
|
||||
const age = end.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = end.getMonth() - birth.getMonth();
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && end.getDate() < birth.getDate())) {
|
||||
return age - 1;
|
||||
}
|
||||
return age;
|
||||
};
|
||||
|
||||
const getGenderText = (gender: number) => {
|
||||
switch (gender) {
|
||||
case 1:
|
||||
return "Kobieta";
|
||||
case 2:
|
||||
return "Mężczyzna";
|
||||
case 3:
|
||||
return "Niebinarne";
|
||||
default:
|
||||
return "Nie określono";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="pt-16 pb-8">
|
||||
<div className="relative">
|
||||
{/* Navigation */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 px-6">
|
||||
<BackButton />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 px-6 lg:px-8 mt-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Profile photo */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={
|
||||
personDetails.profile_path
|
||||
? `https://image.tmdb.org/t/p/w500${personDetails.profile_path}`
|
||||
: "/api/placeholder/400/600"
|
||||
}
|
||||
alt={personDetails.name}
|
||||
className="w-80 h-auto rounded-2xl shadow-2xl shadow-purple-500/20 group-hover:shadow-purple-500/40 transition-all duration-500"
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-t from-purple-600/20 to-transparent opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actor details */}
|
||||
<div className="flex-1 text-white">
|
||||
<div className="space-y-6">
|
||||
{/* Name and known for */}
|
||||
<div>
|
||||
<h1 className="text-4xl lg:text-5xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
|
||||
{personDetails.name}
|
||||
</h1>
|
||||
{personDetails.birthday && (
|
||||
<span className="text-sm text-gray-400">
|
||||
(
|
||||
{calculateAge(
|
||||
personDetails.birthday,
|
||||
personDetails.deathday
|
||||
)}{" "}
|
||||
lat
|
||||
{personDetails.deathday && " w chwili śmierci"})
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mb-4 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaTheaterMasks className="text-purple-400" />
|
||||
<span className="text-xl text-gray-300 font-medium">
|
||||
{personDetails.known_for_department}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<FaStar className="text-yellow-400" />
|
||||
<span className="text-lg text-gray-300">
|
||||
{Math.round(personDetails.popularity)} popularność
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Also known as */}
|
||||
{personDetails.also_known_as.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm">
|
||||
Znany również jako:{" "}
|
||||
{personDetails.also_known_as.slice(0, 3).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Personal info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Gender */}
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<span className="text-purple-400">Płeć:</span>
|
||||
<span>{getGenderText(personDetails.gender)}</span>
|
||||
</div>
|
||||
|
||||
{/* Birthday */}
|
||||
{personDetails.birthday && (
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<FaCalendarAlt className="text-purple-400" />
|
||||
<div className="flex">
|
||||
<span>{formatDate(personDetails.birthday)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deathday */}
|
||||
{personDetails.deathday && (
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<FaCalendarAlt className="text-red-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-red-300">Data śmierci:</span>
|
||||
<span>{formatDate(personDetails.deathday)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Place of birth */}
|
||||
{personDetails.place_of_birth && (
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<FaMapMarkerAlt className="text-purple-400" />
|
||||
<span>{personDetails.place_of_birth}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Biography */}
|
||||
{personDetails.biography && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 text-purple-300">
|
||||
Biografia
|
||||
</h3>
|
||||
<div className="text-gray-300 leading-relaxed text-lg space-y-4">
|
||||
{personDetails.biography
|
||||
.split("\n\n")
|
||||
.map((paragraph, index) => (
|
||||
<p key={index}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External links */}
|
||||
{personDetails.external_ids && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 text-purple-300">
|
||||
Linki
|
||||
</h3>
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(personDetails.external_ids).map(
|
||||
([key, value]) => {
|
||||
if (!(key in externalIdsMap) || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { label, icon, url } =
|
||||
externalIdsMap[
|
||||
key as keyof typeof externalIdsMap
|
||||
];
|
||||
return (
|
||||
<a
|
||||
href={url(value as string)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 px-4 py-2 rounded-xl transition-all duration-300 border border-white/20"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const externalIdsMap = {
|
||||
facebook_id: {
|
||||
label: "Facebook",
|
||||
icon: <FaFacebook />,
|
||||
url: (id: string) => `https://www.facebook.com/${id}`,
|
||||
},
|
||||
instagram_id: {
|
||||
label: "Instagram",
|
||||
icon: <FaInstagram />,
|
||||
url: (id: string) => `https://www.instagram.com/${id}`,
|
||||
},
|
||||
twitter_id: {
|
||||
label: "Twitter",
|
||||
icon: <FaTwitter />,
|
||||
url: (id: string) => `https://www.twitter.com/${id}`,
|
||||
},
|
||||
tiktok_id: {
|
||||
label: "TikTok",
|
||||
icon: <FaTiktok />,
|
||||
url: (id: string) => `https://www.tiktok.com/${id}`,
|
||||
},
|
||||
youtube_id: {
|
||||
label: "YouTube",
|
||||
icon: <FaYoutube />,
|
||||
url: (id: string) => `https://www.youtube.com/${id}`,
|
||||
},
|
||||
imdb_id: {
|
||||
label: "IMDb",
|
||||
icon: <FaImdb />,
|
||||
url: (id: string) => `https://www.imdb.com/name/${id}`,
|
||||
},
|
||||
tvrage_id: {
|
||||
label: "TVRage",
|
||||
icon: null,
|
||||
url: (id: string) => `https://www.tvrage.com/people/${id}`,
|
||||
},
|
||||
wikidata_id: {
|
||||
label: "Wikidata",
|
||||
icon: null,
|
||||
url: (id: string) => `https://www.wikidata.org/wiki/${id}`,
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import { MovieDetailsRich } from "@/lib/tmdb/types";
|
||||
import { FC, useState } from "react";
|
||||
|
|
@ -31,7 +32,11 @@ export const MovieCast: FC<Props> = ({ movieDetails }) => {
|
|||
<h2 className="text-2xl font-bold text-white mb-6">Obsada</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{mainCast.map((actor) => (
|
||||
<div key={actor.id} className="text-center group">
|
||||
<Link
|
||||
key={actor.id}
|
||||
href={`/aktor/${actor.id}`}
|
||||
className="text-center group block cursor-pointer"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl mb-3">
|
||||
<img
|
||||
style={{
|
||||
|
|
@ -46,10 +51,19 @@ export const MovieCast: FC<Props> = ({ movieDetails }) => {
|
|||
className="w-full object-cover group-hover:scale-110 transition-transform duration-500 bg-gradient-to-br from-purple-500/20 to-cyan-500/20"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
{/* Hover overlay with link indication */}
|
||||
<div className="absolute inset-0 bg-purple-600/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-white text-sm font-medium">
|
||||
Zobacz profil
|
||||
</div>
|
||||
<h4 className="font-semibold text-white">{actor.name}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-white group-hover:text-purple-300 transition-colors duration-300">
|
||||
{actor.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400">{actor.character}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import {
|
|||
search,
|
||||
getMovieDetails,
|
||||
getMovieCredits,
|
||||
getMovieDetailsRich,
|
||||
getPersonDetails,
|
||||
} from "./server";
|
||||
|
||||
export const TMDB = {
|
||||
search,
|
||||
getMovieDetails,
|
||||
getMovieCredits,
|
||||
getMovieDetailsRich,
|
||||
getPersonDetails,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import {
|
|||
MovieDetails,
|
||||
MovieCredits,
|
||||
MovieDetailsRich,
|
||||
PersonDetails,
|
||||
PersonMovieCredits,
|
||||
PersonImages,
|
||||
PersonDetailsRich,
|
||||
} from "./types";
|
||||
|
||||
const url = "https://api.themoviedb.org/3";
|
||||
|
|
@ -65,11 +69,7 @@ export async function getUpcomingMovies(): Promise<SearchResult> {
|
|||
return await fetchTmbd("/movie/upcoming?language=pl®ion=PL");
|
||||
}
|
||||
|
||||
export async function getMovieDetails(movieId: number): Promise<MovieDetails> {
|
||||
return await fetchTmbd(`/movie/${movieId}?language=pl&`);
|
||||
}
|
||||
|
||||
export async function getMovieDetailsRich(
|
||||
export async function getMovieDetails(
|
||||
movieId: number
|
||||
): Promise<MovieDetailsRich> {
|
||||
return await fetchTmbd(
|
||||
|
|
@ -80,3 +80,11 @@ export async function getMovieDetailsRich(
|
|||
export async function getMovieCredits(movieId: number): Promise<MovieCredits> {
|
||||
return await fetchTmbd(`/movie/${movieId}/credits?language=pl`);
|
||||
}
|
||||
|
||||
export async function getPersonDetails(
|
||||
personId: number
|
||||
): Promise<PersonDetailsRich> {
|
||||
return await fetchTmbd(
|
||||
`/person/${personId}?language=pl&append_to_response=movie_credits,images,external_ids`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,98 @@ export type MovieCredits = {
|
|||
crew: CrewMember[];
|
||||
};
|
||||
|
||||
export type PersonDetails = {
|
||||
adult: boolean;
|
||||
also_known_as: string[];
|
||||
biography: string;
|
||||
birthday: string | null;
|
||||
deathday: string | null;
|
||||
gender: number;
|
||||
homepage: string | null;
|
||||
id: number;
|
||||
imdb_id: string | null;
|
||||
known_for_department: string;
|
||||
name: string;
|
||||
place_of_birth: string | null;
|
||||
popularity: number;
|
||||
profile_path: string | null;
|
||||
};
|
||||
|
||||
export type PersonExternalIds = {
|
||||
id: number;
|
||||
freebase_mid: string | null;
|
||||
freebase_id: string | null;
|
||||
imdb_id: string | null;
|
||||
tvrage_id: number | null;
|
||||
wikidata_id: string | null;
|
||||
facebook_id: string | null;
|
||||
instagram_id: string | null;
|
||||
twitter_id: string | null;
|
||||
tiktok_id: string | null;
|
||||
youtube_id: string | null;
|
||||
};
|
||||
|
||||
export type PersonMovieCredits = {
|
||||
cast: {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
character: string;
|
||||
credit_id: string;
|
||||
order: number;
|
||||
}[];
|
||||
crew: {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
credit_id: string;
|
||||
department: string;
|
||||
job: string;
|
||||
}[];
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type PersonImages = {
|
||||
id: number;
|
||||
profiles: {
|
||||
aspect_ratio: number;
|
||||
height: number;
|
||||
iso_639_1: string | null;
|
||||
file_path: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
width: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type PersonDetailsRich = PersonDetails & {
|
||||
movie_credits: PersonMovieCredits;
|
||||
images: PersonImages;
|
||||
external_ids: PersonExternalIds;
|
||||
};
|
||||
|
||||
export type MovieDetailsRich = MovieDetails & {
|
||||
credits: MovieCredits;
|
||||
similar: SearchResult;
|
||||
|
|
|
|||
Loading…
Reference in New Issue