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,21 @@
import { HeroMovie } from "@/components/molecules/HeroMovie";
import { MovieCast } from "@/components/molecules/MovieCast";
import { TMDB } from "@/lib/tmdb";
// Main movie details component.
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const movieId = Number((await params).id);
const movieDetails = await TMDB.getMovieDetailsRich(movieId);
return (
<div className="min-h-screen mt-16">
<HeroMovie movieDetails={movieDetails} />
<MovieCast movieDetails={movieDetails} />
</div>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import { FaArrowLeft } from "react-icons/fa";
import { useRouter } from "next/navigation";
import { FC } from "react";
type Props = {
label?: string;
};
export const BackButton: FC<Props> = ({ label = "Powrót" }) => {
const router = useRouter();
return (
<button
onClick={() => router.back()}
className="flex items-center gap-3 text-white hover:text-purple-300 transition-colors group"
>
<div className="p-2 rounded-full bg-black/30 group-hover:bg-purple-600/30 transition-colors">
<FaArrowLeft size={20} />
</div>
<span className="font-medium">{label}</span>
</button>
);
};

View File

@ -0,0 +1,13 @@
import { FC } from "react";
type Props = {
genre: string;
};
export const GenreLabel: FC<Props> = ({ genre }) => {
return (
<span className="px-3 py-1 bg-gradient-to-r from-purple-600/30 to-pink-600/30 rounded-full text-sm border border-purple-400/30">
{genre}
</span>
);
};

View File

@ -1,8 +1,9 @@
"use client";
import { FC, useState } from "react";
import Link from "next/link";
import { MdFavorite } from "react-icons/md";
import { RxEyeOpen } from "react-icons/rx";
import { FaFire, FaTrash } from "react-icons/fa";
import { FaFire, FaTrash, FaInfoCircle } from "react-icons/fa";
import { RiCalendarCheckLine, RiCalendarScheduleLine } from "react-icons/ri";
import { Movie } from "@/types/global";
@ -20,6 +21,7 @@ interface AuroraLayoutProps extends Movie {
}
export const AuroraLayout: FC<AuroraLayoutProps> = ({
id,
title,
overview,
popularity,
@ -202,6 +204,18 @@ export const AuroraLayout: FC<AuroraLayoutProps> = ({
</div>
</div>
<div className="flex items-center gap-3">
{/* Zobacz więcej button */}
<Link
href={`/film/${id}`}
className="opacity-0 group-hover:opacity-100 transition-all duration-300 transform group-hover:scale-105"
>
<div className="flex items-center gap-2 bg-gradient-to-r from-purple-600/90 to-pink-600/90 hover:from-purple-500 hover:to-pink-500 px-3 py-2 rounded-lg text-white text-sm font-medium shadow-lg border border-white/10 transition-all duration-300">
<FaInfoCircle size={14} />
<span>Zobacz więcej</span>
</div>
</Link>
{alreadyInStore && (
<div className="flex gap-2">
{seen && (
@ -220,6 +234,7 @@ export const AuroraLayout: FC<AuroraLayoutProps> = ({
)}
</div>
</div>
</div>
{/* Decorative border glow */}
<div className="absolute inset-0 rounded-2xl border border-transparent bg-gradient-to-r from-purple-500/20 via-transparent to-cyan-500/20 opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none"></div>

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

View File

@ -1,5 +1,13 @@
import { search } from "./server";
import {
search,
getMovieDetails,
getMovieCredits,
getMovieDetailsRich,
} from "./server";
export const TMDB = {
search,
getMovieDetails,
getMovieCredits,
getMovieDetailsRich,
};

View File

@ -1,6 +1,11 @@
"use server";
import { SearchResult } from "./types";
import {
SearchResult,
MovieDetails,
MovieCredits,
MovieDetailsRich,
} from "./types";
const url = "https://api.themoviedb.org/3";
const fetchTmbd = async (path: string) => {
@ -41,21 +46,37 @@ export async function search(options: SearchOptions): Promise<SearchResult> {
export async function getPopularMovies(
page: number = 1
): Promise<SearchResult> {
return await fetchTmbd(`/movie/popular?language=pl-PL&page=${page}`);
return await fetchTmbd(`/movie/popular?language=pl&page=${page}`);
}
export async function getTrendingMovies(): Promise<SearchResult> {
return await fetchTmbd("/trending/movie/day?language=pl-PL");
return await fetchTmbd("/trending/movie/day?language=pl");
}
export async function getNowPlayingMovies(
page: number = 1
): Promise<SearchResult> {
return await fetchTmbd(
`/movie/now_playing?language=pl-PL&region=PL&page=${page}`
`/movie/now_playing?language=pl&region=PL&page=${page}`
);
}
export async function getUpcomingMovies(): Promise<SearchResult> {
return await fetchTmbd("/movie/upcoming?language=pl-PL&region=PL");
return await fetchTmbd("/movie/upcoming?language=pl&region=PL");
}
export async function getMovieDetails(movieId: number): Promise<MovieDetails> {
return await fetchTmbd(`/movie/${movieId}?language=pl&`);
}
export async function getMovieDetailsRich(
movieId: number
): Promise<MovieDetailsRich> {
return await fetchTmbd(
`/movie/${movieId}?language=pl&append_to_response=credits,similar,images,recommendations`
);
}
export async function getMovieCredits(movieId: number): Promise<MovieCredits> {
return await fetchTmbd(`/movie/${movieId}/credits?language=pl`);
}

View File

@ -19,3 +19,120 @@ export type SearchResult = {
total_pages: number;
total_results: number;
};
export type Genre = {
id: number;
name: string;
};
export type ProductionCompany = {
id: number;
logo_path: string | null;
name: string;
origin_country: string;
};
export type ProductionCountry = {
iso_3166_1: string;
name: string;
};
export type SpokenLanguage = {
english_name: string;
iso_639_1: string;
name: string;
};
export type MovieDetails = {
adult: boolean;
backdrop_path: string | null;
belongs_to_collection: {
id: number;
name: string;
poster_path: string | null;
backdrop_path: string | null;
} | null;
budget: number;
genres: Genre[];
homepage: string | null;
id: number;
imdb_id: string | null;
original_language: string;
original_title: string;
overview: string | null;
popularity: number;
poster_path: string | null;
production_companies: ProductionCompany[];
production_countries: ProductionCountry[];
release_date: string;
revenue: number;
runtime: number | null;
spoken_languages: SpokenLanguage[];
status: string;
tagline: string | null;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
};
export type CastMember = {
adult: boolean;
gender: number;
id: number;
known_for_department: string;
name: string;
original_name: string;
popularity: number;
profile_path: string | null;
cast_id: number;
character: string;
credit_id: string;
order: number;
};
export type CrewMember = {
adult: boolean;
gender: number;
id: number;
known_for_department: string;
name: string;
original_name: string;
popularity: number;
profile_path: string | null;
credit_id: string;
department: string;
job: string;
};
export type MovieCredits = {
id: number;
cast: CastMember[];
crew: CrewMember[];
};
export type MovieDetailsRich = MovieDetails & {
credits: MovieCredits;
similar: SearchResult;
recommendations: SearchResult;
images: {
backdrops: {
aspect_ratio: number;
file_path: string;
height: number;
width: number;
}[];
logos: {
aspect_ratio: number;
file_path: string;
height: number;
width: number;
}[];
posters: {
aspect_ratio: number;
file_path: string;
height: number;
width: number;
}[];
};
};