Add Hero component for movie carousel: implement auto-rotation, navigation controls, and movie details display, including add/remove functionality for user favorites.
This commit is contained in:
		
							parent
							
								
									25e5b90ee8
								
							
						
					
					
						commit
						a230a4cf45
					
				|  | @ -0,0 +1,248 @@ | ||||||
|  | "use client"; | ||||||
|  | import { FC, useState, useEffect, useCallback } from "react"; | ||||||
|  | import { Movie } from "@/types/global"; | ||||||
|  | import { | ||||||
|  |   FaPlus, | ||||||
|  |   FaFire, | ||||||
|  |   FaChevronLeft, | ||||||
|  |   FaChevronRight, | ||||||
|  |   FaMinus, | ||||||
|  | } from "react-icons/fa"; | ||||||
|  | import { RiCalendarCheckLine, RiCalendarScheduleLine } from "react-icons/ri"; | ||||||
|  | import { useGlobalStore } from "@/app/store/globalStore"; | ||||||
|  | import { addMovie, deleteMovie } from "@/lib/db"; | ||||||
|  | import { ReadMore } from "@/components/atoms/ReadMore"; | ||||||
|  | 
 | ||||||
|  | type Props = { | ||||||
|  |   movies: Movie[]; | ||||||
|  |   preheading?: string; | ||||||
|  |   autoRotate?: boolean; | ||||||
|  |   rotateInterval?: number; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const Hero: FC<Props> = ({ | ||||||
|  |   movies, | ||||||
|  |   preheading, | ||||||
|  |   autoRotate = true, | ||||||
|  |   rotateInterval = 10000, | ||||||
|  | }) => { | ||||||
|  |   const [currentIndex, setCurrentIndex] = useState(0); | ||||||
|  |   const [isTransitioning, setIsTransitioning] = useState(false); | ||||||
|  | 
 | ||||||
|  |   const { | ||||||
|  |     movies: storedMovies, | ||||||
|  |     addMovie: addMovieToStore, | ||||||
|  |     deleteMovie: deleteMovieInStore, | ||||||
|  |   } = useGlobalStore(); | ||||||
|  | 
 | ||||||
|  |   const currentMovie = movies[currentIndex]; | ||||||
|  | 
 | ||||||
|  |   if (!currentMovie) return null; | ||||||
|  | 
 | ||||||
|  |   const { | ||||||
|  |     id, | ||||||
|  |     title, | ||||||
|  |     overview, | ||||||
|  |     backdrop_path, | ||||||
|  |     poster_path, | ||||||
|  |     release_date, | ||||||
|  |     popularity, | ||||||
|  |     vote_average, | ||||||
|  |   } = currentMovie; | ||||||
|  | 
 | ||||||
|  |   const alreadyInStore = storedMovies.find((m) => m.id === id); | ||||||
|  |   const isReleased = new Date(release_date) < new Date(); | ||||||
|  |   const releaseDate = new Date(release_date); | ||||||
|  | 
 | ||||||
|  |   const nextSlide = useCallback(() => { | ||||||
|  |     if (isTransitioning) return; | ||||||
|  |     setIsTransitioning(true); | ||||||
|  |     setTimeout(() => { | ||||||
|  |       setCurrentIndex((prev) => (prev + 1) % movies.length); | ||||||
|  |       setIsTransitioning(false); | ||||||
|  |     }, 500); | ||||||
|  |   }, [movies.length, isTransitioning]); | ||||||
|  | 
 | ||||||
|  |   const prevSlide = useCallback(() => { | ||||||
|  |     if (isTransitioning) return; | ||||||
|  |     setIsTransitioning(true); | ||||||
|  |     setTimeout(() => { | ||||||
|  |       setCurrentIndex((prev) => (prev - 1 + movies.length) % movies.length); | ||||||
|  |       setIsTransitioning(false); | ||||||
|  |     }, 500); | ||||||
|  |   }, [movies.length, isTransitioning]); | ||||||
|  | 
 | ||||||
|  |   const goToSlide = useCallback( | ||||||
|  |     (index: number) => { | ||||||
|  |       if (isTransitioning || index === currentIndex) return; | ||||||
|  |       setIsTransitioning(true); | ||||||
|  |       setTimeout(() => { | ||||||
|  |         setCurrentIndex(index); | ||||||
|  |         setIsTransitioning(false); | ||||||
|  |       }, 500); | ||||||
|  |     }, | ||||||
|  |     [currentIndex, isTransitioning] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   // Auto-rotate functionality.
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!autoRotate || movies.length <= 1) return; | ||||||
|  | 
 | ||||||
|  |     const interval = setInterval(nextSlide, rotateInterval); | ||||||
|  |     return () => clearInterval(interval); | ||||||
|  |   }, [autoRotate, rotateInterval, nextSlide, movies.length]); | ||||||
|  | 
 | ||||||
|  |   const handleAdd = async () => { | ||||||
|  |     await addMovie(currentMovie); | ||||||
|  |     addMovieToStore(currentMovie); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleRemove = async () => { | ||||||
|  |     await deleteMovie(id); | ||||||
|  |     deleteMovieInStore(id); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <section className="relative min-h-[70vh] flex items-center overflow-hidden pt-10 pb-20"> | ||||||
|  |       {/* Background carousel */} | ||||||
|  |       <div className="absolute inset-0"> | ||||||
|  |         {movies.map((movie, index) => ( | ||||||
|  |           <div | ||||||
|  |             key={movie.id} | ||||||
|  |             className={`absolute inset-0 transition-opacity duration-500 ${ | ||||||
|  |               index === currentIndex ? "opacity-100" : "opacity-0" | ||||||
|  |             }`}
 | ||||||
|  |           > | ||||||
|  |             <img | ||||||
|  |               src={`http://image.tmdb.org/t/p/w1280${backdrop_path}`} | ||||||
|  |               alt={movie.title} | ||||||
|  |               className="w-full h-full object-cover" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |         ))} | ||||||
|  |         <div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-transparent" /> | ||||||
|  |         <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/30" /> | ||||||
|  |       </div> | ||||||
|  | 
 | ||||||
|  |       {/* Navigation arrows */} | ||||||
|  |       {movies.length > 1 && ( | ||||||
|  |         <> | ||||||
|  |           <button | ||||||
|  |             onClick={prevSlide} | ||||||
|  |             disabled={isTransitioning} | ||||||
|  |             className="absolute left-4 top-1/2 -translate-y-1/2 z-20 p-3 bg-black/50 hover:bg-black/70 text-white rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  |           > | ||||||
|  |             <FaChevronLeft size={20} /> | ||||||
|  |           </button> | ||||||
|  |           <button | ||||||
|  |             onClick={nextSlide} | ||||||
|  |             disabled={isTransitioning} | ||||||
|  |             className="absolute right-4 top-1/2 -translate-y-1/2 z-20 p-3 bg-black/50 hover:bg-black/70 text-white rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  |           > | ||||||
|  |             <FaChevronRight size={20} /> | ||||||
|  |           </button> | ||||||
|  |         </> | ||||||
|  |       )} | ||||||
|  | 
 | ||||||
|  |       {/* Content with fade transitions */} | ||||||
|  |       <div className="container relative z-10"> | ||||||
|  |         <div | ||||||
|  |           className={`flex flex-col lg:flex-row items-center gap-8 lg:gap-12 transition-opacity duration-500 ${ | ||||||
|  |             isTransitioning ? "opacity-0" : "opacity-100" | ||||||
|  |           }`}
 | ||||||
|  |         > | ||||||
|  |           {/* Poster */} | ||||||
|  |           <div className="flex-shrink-0"> | ||||||
|  |             <img | ||||||
|  |               src={`http://image.tmdb.org/t/p/w500${poster_path}`} | ||||||
|  |               alt={title} | ||||||
|  |               className="w-64 h-96 object-cover rounded-lg shadow-2xl" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  | 
 | ||||||
|  |           {/* Movie details */} | ||||||
|  |           <div className="flex-1 text-center lg:text-left"> | ||||||
|  |             {preheading && ( | ||||||
|  |               <h3 className="font-bold text-white leading-tight mb-2"> | ||||||
|  |                 {preheading} | ||||||
|  |               </h3> | ||||||
|  |             )} | ||||||
|  |             <h2 className="text-4xl lg:text-6xl font-bold text-white mb-4 leading-tight"> | ||||||
|  |               {title} | ||||||
|  |             </h2> | ||||||
|  | 
 | ||||||
|  |             {/* Movie meta info */} | ||||||
|  |             <div className="flex flex-wrap items-center justify-center lg:justify-start gap-4 mb-6 text-white/80"> | ||||||
|  |               <div className="flex items-center gap-2"> | ||||||
|  |                 <span | ||||||
|  |                   className={`flex items-center gap-1 text-sm ${ | ||||||
|  |                     isReleased ? "text-green-400" : "text-yellow-400" | ||||||
|  |                   }`}
 | ||||||
|  |                 > | ||||||
|  |                   {isReleased ? ( | ||||||
|  |                     <RiCalendarCheckLine /> | ||||||
|  |                   ) : ( | ||||||
|  |                     <RiCalendarScheduleLine /> | ||||||
|  |                   )} | ||||||
|  |                   {releaseDate.toLocaleDateString("pl-PL", { | ||||||
|  |                     day: "numeric", | ||||||
|  |                     month: "long", | ||||||
|  |                     year: "numeric", | ||||||
|  |                   })} | ||||||
|  |                 </span> | ||||||
|  |               </div> | ||||||
|  | 
 | ||||||
|  |               <div className="flex items-center gap-1 text-sm"> | ||||||
|  |                 <FaFire className="text-orange-400" /> | ||||||
|  |                 <span>{popularity.toFixed(1)}</span> | ||||||
|  |               </div> | ||||||
|  | 
 | ||||||
|  |               <div className="flex items-center gap-1 text-sm"> | ||||||
|  |                 <span className="text-yellow-400">★</span> | ||||||
|  |                 <span>{vote_average.toFixed(1)}/10</span> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             {/* Overview */} | ||||||
|  |             <div className="text-lg lg:text-xl text-white/90 mb-8 max-w-2xl leading-relaxed"> | ||||||
|  |               <ReadMore text={overview} /> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             {/* Action buttons */} | ||||||
|  |             <div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start"> | ||||||
|  |               <button | ||||||
|  |                 onClick={alreadyInStore ? handleRemove : handleAdd} | ||||||
|  |                 className={`flex items-center justify-center gap-3 px-8 py-3 rounded-lg font-semibold text-lg text-white transition-colors cursor-pointer ${ | ||||||
|  |                   alreadyInStore | ||||||
|  |                     ? "bg-red-500 hover:bg-red-600" | ||||||
|  |                     : "bg-primary hover:bg-primary/80" | ||||||
|  |                 }`}
 | ||||||
|  |               > | ||||||
|  |                 {alreadyInStore ? <FaMinus /> : <FaPlus />} | ||||||
|  |                 {alreadyInStore ? "Usuń z listy" : "Dodaj do listy"} | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  | 
 | ||||||
|  |       {/* Dot indicators */} | ||||||
|  |       {movies.length > 1 && ( | ||||||
|  |         <div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex gap-2"> | ||||||
|  |           {movies.map((_, index) => ( | ||||||
|  |             <button | ||||||
|  |               key={index} | ||||||
|  |               onClick={() => goToSlide(index)} | ||||||
|  |               disabled={isTransitioning} | ||||||
|  |               className={`w-3 h-3 rounded-full transition-all duration-300 disabled:cursor-not-allowed cursor-pointer ${ | ||||||
|  |                 index === currentIndex | ||||||
|  |                   ? "bg-secondary scale-125" | ||||||
|  |                   : "bg-secondary/50 hover:bg-secondary/70" | ||||||
|  |               }`}
 | ||||||
|  |             /> | ||||||
|  |           ))} | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </section> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
		Loading…
	
		Reference in New Issue