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:
Norbert Maciaszek 2025-08-18 00:43:59 +02:00
parent 7a7bc2575b
commit cf7ec070fd
6 changed files with 412 additions and 10 deletions

View File

@ -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>
);
}

View File

@ -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}`,
},
};

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import Link from "next/link";
import { Button } from "@/components/atoms/Button"; import { Button } from "@/components/atoms/Button";
import { MovieDetailsRich } from "@/lib/tmdb/types"; import { MovieDetailsRich } from "@/lib/tmdb/types";
import { FC, useState } from "react"; 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> <h2 className="text-2xl font-bold text-white mb-6">Obsada</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{mainCast.map((actor) => ( {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"> <div className="relative overflow-hidden rounded-xl mb-3">
<img <img
style={{ 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" 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" /> <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>
</div>
</div> </div>
<h4 className="font-semibold text-white">{actor.name}</h4> <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> <p className="text-sm text-gray-400">{actor.character}</p>
</div> </Link>
))} ))}
</div> </div>

View File

@ -2,12 +2,12 @@ import {
search, search,
getMovieDetails, getMovieDetails,
getMovieCredits, getMovieCredits,
getMovieDetailsRich, getPersonDetails,
} from "./server"; } from "./server";
export const TMDB = { export const TMDB = {
search, search,
getMovieDetails, getMovieDetails,
getMovieCredits, getMovieCredits,
getMovieDetailsRich, getPersonDetails,
}; };

View File

@ -5,6 +5,10 @@ import {
MovieDetails, MovieDetails,
MovieCredits, MovieCredits,
MovieDetailsRich, MovieDetailsRich,
PersonDetails,
PersonMovieCredits,
PersonImages,
PersonDetailsRich,
} from "./types"; } from "./types";
const url = "https://api.themoviedb.org/3"; const url = "https://api.themoviedb.org/3";
@ -65,11 +69,7 @@ export async function getUpcomingMovies(): Promise<SearchResult> {
return await fetchTmbd("/movie/upcoming?language=pl&region=PL"); return await fetchTmbd("/movie/upcoming?language=pl&region=PL");
} }
export async function getMovieDetails(movieId: number): Promise<MovieDetails> { export async function getMovieDetails(
return await fetchTmbd(`/movie/${movieId}?language=pl&`);
}
export async function getMovieDetailsRich(
movieId: number movieId: number
): Promise<MovieDetailsRich> { ): Promise<MovieDetailsRich> {
return await fetchTmbd( return await fetchTmbd(
@ -80,3 +80,11 @@ export async function getMovieDetailsRich(
export async function getMovieCredits(movieId: number): Promise<MovieCredits> { export async function getMovieCredits(movieId: number): Promise<MovieCredits> {
return await fetchTmbd(`/movie/${movieId}/credits?language=pl`); 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`
);
}

View File

@ -111,6 +111,98 @@ export type MovieCredits = {
crew: CrewMember[]; 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 & { export type MovieDetailsRich = MovieDetails & {
credits: MovieCredits; credits: MovieCredits;
similar: SearchResult; similar: SearchResult;