From 61395ca1ec1945bfb62c1336248129121fcb8779 Mon Sep 17 00:00:00 2001 From: Norbert Maciaszek Date: Sun, 17 Aug 2025 19:56:38 +0200 Subject: [PATCH] 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. --- src/app/film/[id]/page.tsx | 21 ++ src/components/atoms/BackButton/index.tsx | 24 ++ src/components/atoms/GenreLabel/index.tsx | 13 + .../atoms/MovieCard/layouts/AuroraLayout.tsx | 49 ++-- src/components/molecules/HeroMovie/index.tsx | 249 ++++++++++++++++++ src/components/molecules/MovieCast/index.tsx | 144 ++++++++++ src/lib/tmdb/index.ts | 10 +- src/lib/tmdb/server.ts | 31 ++- src/lib/tmdb/types.ts | 117 ++++++++ 9 files changed, 635 insertions(+), 23 deletions(-) create mode 100644 src/app/film/[id]/page.tsx create mode 100644 src/components/atoms/BackButton/index.tsx create mode 100644 src/components/atoms/GenreLabel/index.tsx create mode 100644 src/components/molecules/HeroMovie/index.tsx create mode 100644 src/components/molecules/MovieCast/index.tsx diff --git a/src/app/film/[id]/page.tsx b/src/app/film/[id]/page.tsx new file mode 100644 index 0000000..c1a90a4 --- /dev/null +++ b/src/app/film/[id]/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/components/atoms/BackButton/index.tsx b/src/components/atoms/BackButton/index.tsx new file mode 100644 index 0000000..f430fda --- /dev/null +++ b/src/components/atoms/BackButton/index.tsx @@ -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 = ({ label = "Powrót" }) => { + const router = useRouter(); + + return ( + + ); +}; diff --git a/src/components/atoms/GenreLabel/index.tsx b/src/components/atoms/GenreLabel/index.tsx new file mode 100644 index 0000000..29c2630 --- /dev/null +++ b/src/components/atoms/GenreLabel/index.tsx @@ -0,0 +1,13 @@ +import { FC } from "react"; + +type Props = { + genre: string; +}; + +export const GenreLabel: FC = ({ genre }) => { + return ( + + {genre} + + ); +}; diff --git a/src/components/atoms/MovieCard/layouts/AuroraLayout.tsx b/src/components/atoms/MovieCard/layouts/AuroraLayout.tsx index 5dee02a..8be99dd 100644 --- a/src/components/atoms/MovieCard/layouts/AuroraLayout.tsx +++ b/src/components/atoms/MovieCard/layouts/AuroraLayout.tsx @@ -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 = ({ + id, title, overview, popularity, @@ -202,22 +204,35 @@ export const AuroraLayout: FC = ({ - {alreadyInStore && ( -
- {seen && ( -
- )} - {favorite && ( -
- )} -
- )} +
+ {/* Zobacz więcej button */} + +
+ + Zobacz więcej +
+ + + {alreadyInStore && ( +
+ {seen && ( +
+ )} + {favorite && ( +
+ )} +
+ )} +
diff --git a/src/components/molecules/HeroMovie/index.tsx b/src/components/molecules/HeroMovie/index.tsx new file mode 100644 index 0000000..0719a35 --- /dev/null +++ b/src/components/molecules/HeroMovie/index.tsx @@ -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 = ({ 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 ( +
+
+ {/* Navigation */} +
+ +
+ + {/* Main content */} +
+
+
+ {/* Movie poster */} +
+
+ {movieDetails.title} +
+
+
+ + {/* Movie details */} +
+
+ {/* Title and rating */} +
+

+ {movieDetails.title} +

+ {movieDetails.tagline && ( +

+ "{movieDetails.tagline}" +

+ )} + +
+
+
+ {[...Array(5)].map((_, i) => ( + + ★ + + ))} +
+ + {movieDetails.vote_average.toFixed(1)} + + + ({movieDetails.vote_count} głosów) + +
+
+
+ + {/* Key info */} +
+ {movieDetails.release_date && ( +
+ + + {new Date(movieDetails.release_date).getFullYear()} + +
+ )} + + {movieDetails.runtime && ( +
+ + {formatRuntime(movieDetails.runtime)} +
+ )} + + {movieDetails.spoken_languages[0] && ( +
+ + {movieDetails.spoken_languages[0].name} +
+ )} + +
+ Status: + {movieDetails.status} +
+
+ + {/* Genres */} + {movieDetails.genres.length > 0 && ( +
+

+ Gatunki +

+
+ {movieDetails.genres.map((genre) => ( + + ))} +
+
+ )} + + {/* Synopsis */} + {movieDetails.overview && ( +
+

+ Opis +

+

+ {movieDetails.overview} +

+
+ )} + + {/* Action buttons */} +
+ + + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/src/components/molecules/MovieCast/index.tsx b/src/components/molecules/MovieCast/index.tsx new file mode 100644 index 0000000..5ae5ee6 --- /dev/null +++ b/src/components/molecules/MovieCast/index.tsx @@ -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 = ({ 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 ( +
+
+
+ {/* Cast */} + {mainCast.length > 0 && ( +
+

Obsada

+
+ {mainCast.map((actor) => ( +
+
+ {actor.name} +
+
+

{actor.name}

+

{actor.character}

+
+ ))} +
+
+ )} + + {/* Additional info */} +
+ {/* Director */} + {director && ( +
+

+ Reżyseria +

+

{director.name}

+
+ )} + + {/* Budget & Revenue */} + {(movieDetails.budget > 0 || movieDetails.revenue > 0) && ( +
+

+ Finanse +

+
+ {movieDetails.budget > 0 && ( +
+ + Budżet: + + {formatCurrency(movieDetails.budget)} + +
+ )} + {movieDetails.revenue > 0 && ( +
+ + Przychody: + + {formatCurrency(movieDetails.revenue)} + +
+ )} +
+
+ )} + + {/* Production companies */} + {movieDetails.production_companies.length > 0 && ( +
+

+ Produkcja +

+
+ {movieDetails.production_companies + .slice(0, 3) + .map((company) => ( +

+ {company.name} +

+ ))} +
+
+ )} + + {/* External links */} + {(movieDetails.homepage || movieDetails.imdb_id) && ( +
+

Linki

+
+ {movieDetails.homepage && ( + + Oficjalna strona + + )} + {movieDetails.imdb_id && ( + + IMDb + + )} +
+
+ )} +
+
+
+
+ ); +}; diff --git a/src/lib/tmdb/index.ts b/src/lib/tmdb/index.ts index 6cc8129..dc8e78e 100644 --- a/src/lib/tmdb/index.ts +++ b/src/lib/tmdb/index.ts @@ -1,5 +1,13 @@ -import { search } from "./server"; +import { + search, + getMovieDetails, + getMovieCredits, + getMovieDetailsRich, +} from "./server"; export const TMDB = { search, + getMovieDetails, + getMovieCredits, + getMovieDetailsRich, }; diff --git a/src/lib/tmdb/server.ts b/src/lib/tmdb/server.ts index f9c0523..fdc5753 100644 --- a/src/lib/tmdb/server.ts +++ b/src/lib/tmdb/server.ts @@ -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 { export async function getPopularMovies( page: number = 1 ): Promise { - return await fetchTmbd(`/movie/popular?language=pl-PL&page=${page}`); + return await fetchTmbd(`/movie/popular?language=pl&page=${page}`); } export async function getTrendingMovies(): Promise { - 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 { return await fetchTmbd( - `/movie/now_playing?language=pl-PL®ion=PL&page=${page}` + `/movie/now_playing?language=pl®ion=PL&page=${page}` ); } export async function getUpcomingMovies(): Promise { - return await fetchTmbd("/movie/upcoming?language=pl-PL®ion=PL"); + return await fetchTmbd("/movie/upcoming?language=pl®ion=PL"); +} + +export async function getMovieDetails(movieId: number): Promise { + return await fetchTmbd(`/movie/${movieId}?language=pl&`); +} + +export async function getMovieDetailsRich( + movieId: number +): Promise { + return await fetchTmbd( + `/movie/${movieId}?language=pl&append_to_response=credits,similar,images,recommendations` + ); +} + +export async function getMovieCredits(movieId: number): Promise { + return await fetchTmbd(`/movie/${movieId}/credits?language=pl`); } diff --git a/src/lib/tmdb/types.ts b/src/lib/tmdb/types.ts index 70b407d..0d35353 100644 --- a/src/lib/tmdb/types.ts +++ b/src/lib/tmdb/types.ts @@ -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; + }[]; + }; +};