Add movie details and cast components: implement Page, HeroMovie, and MovieCast components to display detailed movie information, including cast, genres, and financial data. Integrate new BackButton and GenreLabel components for enhanced navigation and presentation.

This commit is contained in:
Norbert Maciaszek
2025-08-17 19:56:38 +02:00
parent b577a79278
commit 61395ca1ec
9 changed files with 635 additions and 23 deletions

View File

@@ -0,0 +1,249 @@
"use client";
import { BackButton } from "@/components/atoms/BackButton";
import { Button } from "@/components/atoms/Button";
import { GenreLabel } from "@/components/atoms/GenreLabel";
import { MovieDetailsRich } from "@/lib/tmdb/types";
import { useGlobalStore } from "@/app/store/globalStore";
import { FC } from "react";
import {
FaHeart,
FaBookmark,
FaClock,
FaCalendar,
FaGlobe,
FaEye,
} from "react-icons/fa";
type Props = {
movieDetails: MovieDetailsRich;
};
export const HeroMovie: FC<Props> = ({ movieDetails }) => {
const { movies, addMovie, deleteMovie, updateMovie } = useGlobalStore();
// Check if movie is in store and get its state.
const movieInStore = movies.find((m) => m.id === movieDetails.id);
const isInStore = !!movieInStore;
const isFavorite = movieInStore?.favorite || false;
const isSeen = movieInStore?.seen || false;
const formatRuntime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
};
// Convert TMDB movie to our Movie type.
const convertToMovie = () => ({
id: movieDetails.id,
title: movieDetails.title,
adult: movieDetails.adult,
backdrop_path: movieDetails.backdrop_path || "",
genre_ids: movieDetails.genres.map((g) => g.id).join(","),
original_language: movieDetails.original_language,
original_title: movieDetails.original_title,
overview: movieDetails.overview || "",
popularity: movieDetails.popularity,
poster_path: movieDetails.poster_path || "",
release_date: movieDetails.release_date,
video: movieDetails.video,
vote_average: movieDetails.vote_average,
vote_count: movieDetails.vote_count,
favorite: false,
seen: false,
});
const handleAddToList = () => {
if (isInStore) {
deleteMovie(movieDetails.id);
} else {
addMovie(convertToMovie());
}
};
const handleToggleFavorite = () => {
if (!isInStore) {
addMovie({ ...convertToMovie(), favorite: true });
} else {
updateMovie(movieDetails.id, { favorite: !isFavorite });
}
};
const handleToggleSeen = () => {
if (!isInStore) {
addMovie({ ...convertToMovie(), seen: true });
} else {
updateMovie(movieDetails.id, { seen: !isSeen });
}
};
return (
<section className="my-16">
<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">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col lg:flex-row gap-8">
{/* Movie poster */}
<div className="flex-shrink-0">
<div className="relative group">
<img
src={`https://image.tmdb.org/t/p/w500${movieDetails.poster_path}`}
alt={movieDetails.title}
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>
{/* Movie details */}
<div className="flex-1 text-white">
<div className="space-y-6">
{/* Title and rating */}
<div>
<h1 className="text-4xl lg:text-5xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent mb-3">
{movieDetails.title}
</h1>
{movieDetails.tagline && (
<p className="text-xl text-gray-300 italic mb-4">
"{movieDetails.tagline}"
</p>
)}
<div className="flex items-center gap-6 flex-wrap">
<div className="flex items-center gap-2">
<div className="flex">
{[...Array(5)].map((_, i) => (
<span
key={i}
className={`text-2xl ${
i < Math.round(movieDetails.vote_average / 2)
? "text-yellow-400"
: "text-gray-600"
}`}
>
</span>
))}
</div>
<span className="text-lg font-semibold">
{movieDetails.vote_average.toFixed(1)}
</span>
<span className="text-gray-400">
({movieDetails.vote_count} głosów)
</span>
</div>
</div>
</div>
{/* Key info */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{movieDetails.release_date && (
<div className="flex items-center gap-2 text-gray-300">
<FaCalendar className="text-purple-400" />
<span>
{new Date(movieDetails.release_date).getFullYear()}
</span>
</div>
)}
{movieDetails.runtime && (
<div className="flex items-center gap-2 text-gray-300">
<FaClock className="text-purple-400" />
<span>{formatRuntime(movieDetails.runtime)}</span>
</div>
)}
{movieDetails.spoken_languages[0] && (
<div className="flex items-center gap-2 text-gray-300">
<FaGlobe className="text-purple-400" />
<span>{movieDetails.spoken_languages[0].name}</span>
</div>
)}
<div className="flex items-center gap-2 text-gray-300">
<span className="text-purple-400">Status:</span>
<span>{movieDetails.status}</span>
</div>
</div>
{/* Genres */}
{movieDetails.genres.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3 text-purple-300">
Gatunki
</h3>
<div className="flex flex-wrap gap-2">
{movieDetails.genres.map((genre) => (
<GenreLabel key={genre.id} genre={genre.name} />
))}
</div>
</div>
)}
{/* Synopsis */}
{movieDetails.overview && (
<div>
<h3 className="text-lg font-semibold mb-3 text-purple-300">
Opis
</h3>
<p className="text-gray-300 leading-relaxed text-lg">
{movieDetails.overview}
</p>
</div>
)}
{/* Action buttons */}
<div className="flex gap-4 flex-wrap">
<Button
className={`flex items-center gap-3 ${
isInStore
? "bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500"
: ""
}`}
onClick={handleAddToList}
>
<FaBookmark />
{isInStore ? "Usuń z listy" : "Dodaj do listy"}
</Button>
<Button
theme="glass"
className={`flex items-center gap-3 ${
isFavorite
? "bg-gradient-to-r from-rose-600/90 to-pink-600/90 hover:from-rose-500/90 hover:to-pink-500/90 border-rose-400/30"
: ""
}`}
onClick={handleToggleFavorite}
>
<FaHeart className={isFavorite ? "text-rose-200" : ""} />
{isFavorite ? "Usuń z ulubionych" : "Dodaj do ulubionych"}
</Button>
<Button
theme="glass"
className={`flex items-center gap-3 ${
isSeen
? "bg-gradient-to-r from-emerald-600/90 to-teal-600/90 hover:from-emerald-500/90 hover:to-teal-500/90 border-emerald-400/30"
: ""
}`}
onClick={handleToggleSeen}
>
<FaEye className={isSeen ? "text-emerald-200" : ""} />
{isSeen
? "Oznacz jako nieobejrzany"
: "Oznacz jako obejrzany"}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,144 @@
import { MovieDetailsRich } from "@/lib/tmdb/types";
import { FC } from "react";
import { FaDollarSign } from "react-icons/fa";
type Props = {
movieDetails: MovieDetailsRich;
};
export const MovieCast: FC<Props> = ({ movieDetails }) => {
const director = movieDetails?.credits.crew.find(
(member) => member.job === "Director"
);
const mainCast = movieDetails?.credits.cast.slice(0, 6) || [];
const formatCurrency = (amount: number) =>
new Intl.NumberFormat("pl-PL", {
style: "currency",
currency: "USD",
}).format(amount);
return (
<section className="px-6 lg:px-8 py-16">
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-3 gap-12">
{/* Cast */}
{mainCast.length > 0 && (
<div className="lg:col-span-2">
<h2 className="text-2xl font-bold text-white mb-6">Obsada</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
{mainCast.map((actor) => (
<div key={actor.id} className="text-center group">
<div className="relative overflow-hidden rounded-xl mb-3">
<img
src={
actor.profile_path
? `https://image.tmdb.org/t/p/w185${actor.profile_path}`
: "/api/placeholder/185/278"
}
alt={actor.name}
className="w-full h-48 object-cover group-hover:scale-110 transition-transform duration-500"
/>
<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>
<h4 className="font-semibold text-white">{actor.name}</h4>
<p className="text-sm text-gray-400">{actor.character}</p>
</div>
))}
</div>
</div>
)}
{/* Additional info */}
<div className="space-y-8">
{/* Director */}
{director && (
<div>
<h3 className="text-xl font-semibold text-white mb-3">
Reżyseria
</h3>
<p className="text-gray-300">{director.name}</p>
</div>
)}
{/* Budget & Revenue */}
{(movieDetails.budget > 0 || movieDetails.revenue > 0) && (
<div>
<h3 className="text-xl font-semibold text-white mb-3">
Finanse
</h3>
<div className="space-y-2">
{movieDetails.budget > 0 && (
<div className="flex items-center gap-2">
<FaDollarSign className="text-green-400" />
<span className="text-gray-400">Budżet:</span>
<span className="text-white">
{formatCurrency(movieDetails.budget)}
</span>
</div>
)}
{movieDetails.revenue > 0 && (
<div className="flex items-center gap-2">
<FaDollarSign className="text-green-400" />
<span className="text-gray-400">Przychody:</span>
<span className="text-white">
{formatCurrency(movieDetails.revenue)}
</span>
</div>
)}
</div>
</div>
)}
{/* Production companies */}
{movieDetails.production_companies.length > 0 && (
<div>
<h3 className="text-xl font-semibold text-white mb-3">
Produkcja
</h3>
<div className="space-y-2">
{movieDetails.production_companies
.slice(0, 3)
.map((company) => (
<p key={company.id} className="text-gray-300">
{company.name}
</p>
))}
</div>
</div>
)}
{/* External links */}
{(movieDetails.homepage || movieDetails.imdb_id) && (
<div>
<h3 className="text-xl font-semibold text-white mb-3">Linki</h3>
<div className="space-y-2">
{movieDetails.homepage && (
<a
href={movieDetails.homepage}
target="_blank"
rel="noopener noreferrer"
className="block text-purple-400 hover:text-purple-300 transition-colors"
>
Oficjalna strona
</a>
)}
{movieDetails.imdb_id && (
<a
href={`https://www.imdb.com/title/${movieDetails.imdb_id}`}
target="_blank"
rel="noopener noreferrer"
className="block text-purple-400 hover:text-purple-300 transition-colors"
>
IMDb
</a>
)}
</div>
</div>
)}
</div>
</div>
</div>
</section>
);
};