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";
|
"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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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®ion=PL");
|
return await fetchTmbd("/movie/upcoming?language=pl®ion=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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue