diff --git a/.env b/.env new file mode 100644 index 00000000..207eb8af --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_KEY=531687c8e8da8cb3eb85ddce1090cec8 diff --git a/package-lock.json b/package-lock.json index 92a683d2..7808a87d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "prettier": "^3.5.3", "vite": "^5.2.0" } }, @@ -3453,6 +3454,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index eded5715..7d633af1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "prettier": "^3.5.3", "vite": "^5.2.0" } } diff --git a/src/App.css b/src/App.css index 0bf65669..c8c459e6 100644 --- a/src/App.css +++ b/src/App.css @@ -1,28 +1,108 @@ .App { text-align: center; -} + min-width: 700px; -.App-header { + + } + + + .App-header { background-color: #282c34; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-evenly; - color: white; padding: 20px; -} + padding-bottom: 50px; + + } + -@media (max-width: 600px) { + @media (max-width: 600px) { .movie-card { width: 100%; } - .search-bar { - flex-direction: column; - gap: 10px; - } - .search-bar form { - flex-direction: column; - } -} + } + /* .search-bar { + display: inline; + width: 30%; + + + + + } */ + + + + + /* .search-bar form { + flex-direction: column; + } */ + + + /* input{ + width: 70%; + + + + + } */ + + + button{ + width: 30%; + } + + + .movie-container{ + display: flex; + flex-wrap: wrap; + justify-content: space-around; + padding: 10px; + border: 3px solid blue; + height: 100%; + /* background: linear-gradient(to right, blue, black); */ + + } + + + .App-footer{ + justify-content: center; + display: flex; + border: 3px solid red; + height: 100%; + width: 100%; + + + + + } + + + .title{ + color: white + + + } + + + .controls-row{ + display: flex; + justify-content:space-between; + } + + + .search-section{ + margin-right: 80px; + } + + + .load-more button{ + width: 15%; + height: 7vh; + border-radius: 10px; + background-color: green; + } + + .load-more{ + background: linear-gradient(to right, blue, black); + + } diff --git a/src/App.jsx b/src/App.jsx index dfa91584..3f754bf6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,12 +1,65 @@ -import { useState } from 'react' import './App.css' +import Search from './Search.jsx' +import Sort from './Sort.jsx' +import MovieList from './MovieList.jsx' +import {useState} from 'react' -const App = () => { - return ( -
- -
- ) + +function App() { + const [page, setPage] = useState(1) + + const [searchTerm, setSearchTerm] = useState('') + + function increasePageNumber() { + setPage(page + 1) + } + + function handleSearch(term) { + setPage(1) + setSearchTerm(term) + } + + return ( +
+
+

🎥 Flixster

+ + +
+ + +
+ + +
+ + +
+
+ +
+
+ +

Page {page}

+ + +
+ + +
+ + + + + + + +
+ ) } -export default App +export default App; diff --git a/src/Modal.css b/src/Modal.css new file mode 100644 index 00000000..c77611ec --- /dev/null +++ b/src/Modal.css @@ -0,0 +1,112 @@ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.75); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + overflow-y: auto; + padding: 20px; + } + + .modal-content { + background-color: #141414; + border-radius: 8px; + width: 90%; + max-width: 800px; + max-height: 90vh; + overflow-y: auto; + position: relative; + color: white; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); + } + + .modal-close { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.5); + border: none; + color: white; + font-size: 24px; + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10; + } + + .modal-backdrop { + height: 300px; + background-size: cover; + background-position: center top; + position: relative; + } + + .modal-backdrop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, rgba(20, 20, 20, 0) 0%, rgba(20, 20, 20, 1) 100%); + } + + .modal-body { + padding: 20px; + position: relative; + margin-top: -60px; + } + + .modal-info { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; + } + + .modal-info p { + margin: 0; + } + + .modal-overview { + margin-bottom: 20px; + } + + .modal-overview h3 { + margin-bottom: 10px; + } + + .trailer-container { + position: relative; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + height: 0; + overflow: hidden; + margin-top: 10px; + } + + .trailer-container iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 4px; + } + + /* Make movie cards clickable */ + .card-content { + cursor: pointer; + transition: transform 0.2s; + } + + .card-content:hover { + transform: scale(1.05); + } diff --git a/src/Modal.jsx b/src/Modal.jsx new file mode 100644 index 00000000..5a24ec9a --- /dev/null +++ b/src/Modal.jsx @@ -0,0 +1,125 @@ +import './Modal.css' +import { useState, useEffect } from 'react' + +function Modal({ movie, onClose }) { + const [movieDetails, setMovieDetails] = useState(null); + const [trailer, setTrailer] = useState(null); + const [loading, setLoading] = useState(true); + + const backdropUrl = `https://image.tmdb.org/t/p/original${movie.backdrop_path}`; + + useEffect(() => { + // Function to fetch movie details (including runtime) + const fetchMovieDetails = async () => { + try { + const apiKey = import.meta.env.VITE_API_KEY; + const response = await fetch( + `https://api.themoviedb.org/3/movie/${movie.id}?api_key=${apiKey}&language=en-US` + ); + + if (!response.ok) { + throw new Error('Failed to fetch movie details'); + } + + const data = await response.json(); + setMovieDetails(data); + } catch (error) { + console.error('Error fetching movie details:', error); + } + }; + + // Function to fetch movie videos (trailers) + const fetchMovieTrailer = async () => { + try { + const apiKey = import.meta.env.VITE_API_KEY; + const response = await fetch( + `https://api.themoviedb.org/3/movie/${movie.id}/videos?api_key=${apiKey}&language=en-US` + ); + + if (!response.ok) { + throw new Error('Failed to fetch movie videos'); + } + + const data = await response.json(); + + // Find the official trailer + const officialTrailer = data.results.find( + video => video.type === 'Trailer' && video.site === 'YouTube' + ); + + setTrailer(officialTrailer || null); + setLoading(false); + } catch (error) { + console.error('Error fetching movie trailer:', error); + setLoading(false); + } + }; + + // Call both fetch functions + fetchMovieDetails(); + fetchMovieTrailer(); + }, [movie.id]); + + // Format runtime from minutes to hours and minutes + const formatRuntime = (minutes) => { + if (!minutes) return 'Runtime not available'; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours}h ${mins}m`; + }; + + return ( +
+
e.stopPropagation()}> + + +
+
+
+ +
+

{movie.title}

+ + {loading ? ( +

Loading details...

+ ) : ( + <> +
+

Release Date: {movie.release_date}

+ {movieDetails && ( +

Runtime: {formatRuntime(movieDetails.runtime)}

+ )} +

Rating: ⭐ {movie.vote_average.toFixed(1)}/10

+ {movieDetails && movieDetails.genres && ( +

Genres: {movieDetails.genres.map(g => g.name).join(', ')}

+ )} +
+ +
+

Overview

+

{movie.overview}

+
+ + {trailer && ( +
+

Trailer

+
+ +
+
+ )} + + )} +
+
+
+ ); +} + +export default Modal; diff --git a/src/MovieCard.css b/src/MovieCard.css new file mode 100644 index 00000000..e1c9ddc2 --- /dev/null +++ b/src/MovieCard.css @@ -0,0 +1,28 @@ +.card-content{ + display: block; + + + border: 2px solid black; + height: 400px; + width: 280px; + text-align: center; + border-radius: 20px; + } + + + .movie-card img{ + width: 100%; + height: 70%; + border-top-right-radius: 18px; + border-top-left-radius: 18px; + } + + + .movie-card p{ + font-size: 10px; + color: white; + } + + .movie-card{ + box-shadow: 10px black; + } diff --git a/src/MovieCard.jsx b/src/MovieCard.jsx new file mode 100644 index 00000000..97386b86 --- /dev/null +++ b/src/MovieCard.jsx @@ -0,0 +1,45 @@ +import './MovieCard.css' +import Modal from './Modal.jsx' +import {useState} from 'react' + + + + +function MovieCard(props){ + const [showModal, setShowModal] = useState(false); + const posterUrl = `https://image.tmdb.org/t/p/w500${props.movie.poster_path}`; + + const openModal = () => { + setShowModal(true); + }; + + const closeModal = () => { + setShowModal(false); + }; + + return ( +
+
+ {props.movie.title}/ +

{props.movie.title}

+

⭐{props.movie.vote_average}

+

{props.movie.release_date.substring(0,4)}

+
+ + {showModal &&( + + )} +
+ + + + ); +} + + + + +export default MovieCard; diff --git a/src/MovieList.css b/src/MovieList.css new file mode 100644 index 00000000..bcdb038f --- /dev/null +++ b/src/MovieList.css @@ -0,0 +1,9 @@ +.movie-list{ + display: flex; + height: 100%; + width: 100%; + flex-wrap: wrap; + gap: 60px; + padding: 40px; + justify-content: space-evenly; + } diff --git a/src/MovieList.jsx b/src/MovieList.jsx new file mode 100644 index 00000000..74ab1608 --- /dev/null +++ b/src/MovieList.jsx @@ -0,0 +1,74 @@ +import './MovieList.css' +import data from './data/data.js' +import MovieCard from './MovieCard.jsx' +import {useState, useEffect} from 'react' + + + + +function MovieList({page = 1, searchTerm = ''}){ + // state to stores the movies fetched from the API + const [movies, setMovies] = useState([]) + + // state to track if data is currently being loaded + const [isLoading, setIsLoading] = useState(true) + + // state to store any error that might occur during the API fetch + const [error, setError] = useState(null) + + useEffect(() => { + const fetchMovies = async () => { + try { + const apiKey = import.meta.env.VITE_API_KEY; + const apiURL = searchTerm + ? `https://api.themoviedb.org/3/search/movie?api_key=${apiKey}&query=${encodeURIComponent(searchTerm)}&page=${page}` + : `https://api.themoviedb.org/3/movie/popular?api_key=${apiKey}&language=en-US&page=${page}`; + + const response = await fetch(apiURL); + + if (!response.ok){ + throw new Error('Failed to fetch movies'); + } + const data = await response.json(); + + if (page === 1){ + setMovies(data.results); + }else{ + setMovies(prevMovies => [...prevMovies, ...data.results]); + } + + setIsLoading(false); + } catch (err) { + setError(err.message); + setIsLoading(false); + } + }; + fetchMovies(); + }, [page, searchTerm]); // re-reun when page or searchTerm changes + + if (isLoading && page === 1){ + return
Loading...
; + } + + if (error){ + return
An error occurred: {error}
; + } + + return ( +
+ {movies.map((movie) =>( + + )) + + + } + + +
+ ); +} + + + + +export default MovieList; diff --git a/src/Search.css b/src/Search.css new file mode 100644 index 00000000..a22a8727 --- /dev/null +++ b/src/Search.css @@ -0,0 +1,83 @@ +.search-section{ + width: 40%; + display: flex; + + + + + } + + .search-bar-container { + display: flex; + gap: 2px; + } + + + + input { + width: 20vw; + height: 100%; + margin-left: 40px; + padding-right: 30px; + } + + .search-button{ + width: 55px; + margin-left: 1vw; + border-radius: 50%; + height: 40px + } + + + + + .clear-search { + position: absolute; + right: 5px; + background: none; + border: none; + cursor: pointer; + height: 20px; + width: 20px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin-left: 0; + } + + +.close{ + color: green; + font-size: 18px; + margin-right: 60px; +} + +.search-bar-container { + display: flex; + align-items: center; + width: 100%; + } + + .search-bar { + position: relative; + display: flex; + align-items: center; + height: 30px + + } + + .now-playing-button { + background-color: #0d253f; + + color: white; + border: none; + border-radius: 4px; + padding: 8px 12px; + margin-left: 10px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; + height: 40px; + white-space: nowrap; + } diff --git a/src/Search.jsx b/src/Search.jsx new file mode 100644 index 00000000..a464d700 --- /dev/null +++ b/src/Search.jsx @@ -0,0 +1,64 @@ +import "./Search.css"; +import React, { useState } from "react"; + + + +function Search({ onSearch }) { + const [searchTerm, setSearchTerm] = useState(""); + + const handleSubmit = (event) => { + event.preventDefault(); + onSearch(searchTerm); + }; + + const handleClear = () => { + setSearchTerm(""); + onSearch(""); + }; + + const handleNowPlaying = (event) => { + event.preventDefault(); + setSearchTerm(""); + onSearch(""); // Reset search results to default (popular movies) + }; + return ( +
+
+
+
+ setSearchTerm(e.target.value)} + /> + + {searchTerm && ( + + )} +
+ + + +
+
+
+ ); +} + +export default Search; diff --git a/src/Sort.css b/src/Sort.css new file mode 100644 index 00000000..9c4d81ee --- /dev/null +++ b/src/Sort.css @@ -0,0 +1,9 @@ +label{ + color: aliceblue; + } + + + select{ + margin-left: 10px; + margin-right: 120px; + } diff --git a/src/Sort.jsx b/src/Sort.jsx new file mode 100644 index 00000000..2ce1966f --- /dev/null +++ b/src/Sort.jsx @@ -0,0 +1,29 @@ +import './Sort.css' +function Sort() { + return ( +
+
+
+ + + +
+
+ + + + +
+ ) + + + + +} + + +export default Sort; diff --git a/src/index.css b/src/index.css index e1faed1a..3ac430cc 100644 --- a/src/index.css +++ b/src/index.css @@ -2,18 +2,23 @@ body { margin: 0; font-family: Arial, sans-serif; background-color: #f4f4f4; -} -button { + + } + + + + button { background-color: #282c34; color: white; cursor: pointer; font-size: 16px; font-weight: bold; transition: background-color 0.3s ease; -} + } + -button:hover { + button:hover { background-color: #777; color: white; -} + }