Skip to content
Merged

Feat #26

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Profile from "./pages/Profile";
import useAuthLoader from "./hooks/useAuthLoader";
import MoviePage from "./pages/MoviePage";
import Navbar from "./components/Navbar";
import TmdbMovie from "./pages/MoviesPage";

function isTokenExpired(token) {
if (!token) return true;
Expand Down Expand Up @@ -79,6 +80,15 @@ export default function App() {
}
/>

<Route
path="/movies"
element={
token && !isExpired
? <TmdbMovie />
: <Navigate to="/login" />
}
/>

<Route
path="/movies/:movieId"
element={
Expand Down
6 changes: 6 additions & 0 deletions client/src/api/tmdb.api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import axiosInstance from "./axiosInstance";

export const getTrendingMovies = async () => {
const res = await axiosInstance.get('/movies/trending');
return res;
}
4 changes: 2 additions & 2 deletions client/src/components/HistoryTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ export default function HistoryTab() {
>
{/* THE TIMELINE HEADER */}
<div className="sticky top-0 z-10 py-4 bg-white/80 backdrop-blur-md flex items-center gap-4 mb-6">
<div className="h-[1px] w-12 bg-amber-600/30" />
<div className="h-px w-12 bg-amber-600/30" />
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-stone-400">
{date}
</h2>
<div className="h-[1px] flex-1 bg-stone-100" />
<div className="h-px flex-1 bg-stone-100" />
</div>

{/* MOVIE GRID */}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Tabs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default function Tabs({ profileData, isMyProfile }) {
</div>

{/* 2. CONTENT AREA */}
<div className="p-6 md:p-10 min-h-[400px]">
<div className="p-6 md:p-10 min-h-100">
<AnimatePresence mode="wait">
{loading ? (
<motion.div
Expand Down
8 changes: 4 additions & 4 deletions client/src/components/ui/MovieCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ export default function MovieCard({
<img
src={`${import.meta.env.VITE_TMDB_POSTER_BASE_URL}${poster}`}
alt={`${title} Poster`}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
className="w-full h-full object-cover transition-transform duration-700"
draggable="false"
loading="lazy"
/>

{/* Cinematic Vignette Overlay */}
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Vignette Overlay */}
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 hover:opacity-100 transition-opacity duration-500" />

{/* Rating Badge - Updated to Glassmorphism */}
{/* Rating Badge */}
{rating && (
<div className="absolute top-3 right-3 flex items-center gap-1 bg-white/20 backdrop-blur-md border border-white/30 rounded-full px-2 py-1 shadow-xl">
<svg width="10" height="10" viewBox="0 0 24 24" fill="#FBBF24">
Expand Down
3 changes: 1 addition & 2 deletions client/src/pages/MoviePage.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import { getMovieById } from "../api/movie.api";
import Loader from "../components/ui/Loader";
import { useParams } from "react-router-dom";

export default function MoviePage() {
const [movieData, setMovieData] = useState(null);
Expand Down
150 changes: 150 additions & 0 deletions client/src/pages/MoviesPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useEffect, useState, useRef } from "react";
import { motion } from "framer-motion";
import { getTrendingMovies } from "../api/tmdb.api";
import MovieCard from "../components/ui/MovieCard";
import Loader from "../components/ui/Loader";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";

export default function MoviesPage() {
const [loading, setLoading] = useState(true);
const [trendingMovies, setTrendingMovies] = useState([]);
const [featuredMovie, setFeaturedMovie] = useState(null);

useEffect(() => {
const fetchTrending = async () => {
try {
const res = await getTrendingMovies();
const movies = res.data || [];
setTrendingMovies(movies);

if (movies.length > 0) {
const randomIndex = Math.floor(Math.random() * movies.length);
setFeaturedMovie(movies[randomIndex]);
}
} catch (error) {
console.error("Error fetching trending movies: ", error);
} finally {
setLoading(false);
}
};

fetchTrending();
}, []);

if (loading) return <Loader />;

return (
<div className="min-h-screen bg-stone-50 font-sans text-stone-900 selection:bg-amber-200">

{/* 1. FEATURED HERO SECTION */}
<div className="relative w-full h-[60vh] md:h-[80vh] overflow-hidden bg-stone-900">
<motion.img
initial={{ scale: 1.1, opacity: 0 }}
animate={{ scale: 1, opacity: 0.5 }}
transition={{ duration: 1.5 }}
src={`${import.meta.env.VITE_TMDB_BACKDROP_BASE_URL}${featuredMovie?.backdropPath}`}
className="w-full h-full object-cover"
alt="Featured backdrop"
/>

{/* Gradient & Content Overlay */}
<div className="absolute inset-0 bg-linear-to-t from-stone-50 via-stone-900/20 to-transparent flex flex-col justify-end px-6 md:px-12 pb-20">
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.8 }}
className="max-w-3xl"
>
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-amber-500 mb-4">
Featured Spotlight
</h3>
<h1 className="text-4xl md:text-7xl font-black text-stone-900 tracking-tighter mb-4 leading-none">
{featuredMovie?.title || featuredMovie?.name}
</h1>
<p className="text-stone-600 text-sm md:text-lg font-serif italic line-clamp-3 max-w-xl">
{featuredMovie?.overview}
</p>
</motion.div>
</div>
</div>

<main className="px-6 md:px-16 -mt-16 relative z-20 pb-24">
{/* 2. TRENDING SECTION */}
<MovieSlider
title="Trending"
subtitle="Global Cinema"
movies={trendingMovies}
/>
</main>
</div>
);
}

function MovieSlider({ title, subtitle, movies }) {
const scrollRef = useRef(null);

const scroll = (direction) => {
if (scrollRef.current) {
const { scrollLeft, clientWidth } = scrollRef.current;
const scrollAmount = clientWidth * 0.8;
const scrollTo = direction === "left" ? scrollLeft - scrollAmount : scrollLeft + scrollAmount;
scrollRef.current.scrollTo({ left: scrollTo, behavior: "smooth" });
}
};

if (!movies || movies.length === 0) return null;

return (
<section className="relative">
<div className="flex items-end justify-between mb-8 px-2">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-0.5 w-6 bg-amber-500/40" />
<h5 className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">{subtitle}</h5>
</div>
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-stone-900">{title}</h2>
</div>

<div className="flex gap-2">
<button
onClick={() => scroll("left")}
className="w-12 h-12 rounded-2xl bg-white border border-stone-200 flex items-center justify-center text-stone-400 hover:text-stone-900 hover:border-stone-400 active:scale-95 transition-all shadow-sm"
>
<ChevronLeftIcon className="size-5 stroke-[2.5]" />
</button>
<button
onClick={() => scroll("right")}
className="w-12 h-12 rounded-2xl bg-white border border-stone-200 flex items-center justify-center text-stone-400 hover:text-stone-900 hover:border-stone-400 active:scale-95 transition-all shadow-sm"
>
<ChevronRightIcon className="size-5 stroke-[2.5]" />
</button>
</div>
</div>

<div
ref={scrollRef}
className="flex gap-8 overflow-x-auto scrollbar-hide snap-x snap-mandatory px-2 py-4"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
WebkitOverflowScrolling: 'touch'
}}
>
{movies.map((movie) => (
<div
key={movie.id}
className="min-w-40 sm:min-w-50 md:min-w-55 snap-start"
>
<MovieCard
id={movie?._id}
title={movie.title}
poster={movie.posterPath}
rating={movie.voteAverage}
releaseDate={movie.releaseDate}
/>
</div>
))}
</div>
</section>
);
}
12 changes: 5 additions & 7 deletions client/src/pages/Profile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export default function Profile() {

{/* Modal for Cover Selection */}
<Modal open={open} setOpen={setOpen}>
<div className="flex flex-col h-[90vh] max-h-[900px]">
<div className="flex flex-col h-[90vh] max-h-225">
{/* Header */}
<div className="p-6 md:p-10 border-b border-stone-100 bg-white/80 backdrop-blur-md sticky top-0 z-10">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
Expand All @@ -208,7 +208,6 @@ export default function Profile() {
</div>
</div>

{/* Content Area */}
<div className="flex-1 overflow-y-auto p-6 md:p-10 custom-scrollbar bg-stone-50/30">
{banners.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-10">
Expand All @@ -219,19 +218,19 @@ export default function Profile() {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
whileHover={{ scale: 1.02 }}
className={`group relative aspect-video rounded-[2rem] overflow-hidden cursor-pointer bg-stone-200 border-4
className={`group relative aspect-video rounded-4xl overflow-hidden cursor-pointer bg-stone-200 border-4
${selectedBackdrop === banner?.backdropPath ? "border-amber-500/70" : "border-white"}
shadow-xl transition-all hover:shadow-amber-500/20 hover:border-amber-500/40`}
onClick={() => {
setSelectedBackdrop(banner?.backdropPath); // ✅ Select banner
setSelectedBackdrop(banner?.backdropPath);
}}
>
<img
src={`${import.meta.env.VITE_TMDB_BACKDROP_BASE_URL}${banner?.backdropPath}`}
alt={banner?.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900 via-stone-900/20 to-transparent opacity-60 group-hover:opacity-90 transition-opacity duration-300" />
<div className="absolute inset-0 bg-linear-to-t from-stone-900 via-stone-900/20 to-transparent opacity-60 group-hover:opacity-90 transition-opacity duration-300" />
<div className="absolute inset-0 flex flex-col justify-end p-8 translate-y-2 group-hover:translate-y-0 transition-transform duration-300">
<div className="flex items-center justify-between">
<div className="max-w-[80%]">
Expand Down Expand Up @@ -263,7 +262,6 @@ export default function Profile() {
)}
</div>

{/* ✅ Save & Cancel Buttons */}
{selectedBackdrop && (
<div className="p-6 border-t border-stone-100 bg-white/80 backdrop-blur-md sticky bottom-0 flex justify-end gap-4">
<button
Expand All @@ -273,7 +271,7 @@ export default function Profile() {
Cancel
</button>
<button
onClick={handleSaveCover} // ✅ Save cover on click
onClick={handleSaveCover}
className="px-6 py-3 bg-amber-500 text-white rounded-xl font-bold hover:bg-amber-600 transition-all"
>
Save Cover
Expand Down
11 changes: 9 additions & 2 deletions server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,27 @@ import reviewRouter from './routes/review.route.js';
import commentRouter from './routes/comment.route.js';
import profileRouter from './routes/features/profile.route.js';
import movieRouter from './routes/movie.route.js';
import tmdbRouter from './routes/tmdb.routes.js';
import "./cron/trending.cron.js";
import { fetchAndStoreTrending } from './services/trending.service.js';

const app = express();

app.use(cors({
origin: "http://localhost:5173", // allow your React frontend
origin: "http://localhost:5173",
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
credentials: true // if you’re sending cookies
credentials: true
}));

app.use(express.json());
app.use(express.urlencoded({ extended: false}));

// Remove this line when deployed, as the cron will handle it
fetchAndStoreTrending().then(() => console.log("🔥 Initial trending fetched"));

app.use(`${BASE_URL}/auth`, authRouter);
app.use(`${BASE_URL}/user`, userRouter);
app.use(`${BASE_URL}/movies`, tmdbRouter);
app.use(`${BASE_URL}/movies`, movieRouter);
app.use(`${BASE_URL}/history`, historyRouter);
app.use(`${BASE_URL}/watchlist`, watchListRouter);
Expand Down
40 changes: 0 additions & 40 deletions server/config/env.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,3 @@
// // import { config } from 'dotenv';

// // config({ path: `.env.${process.env.NODE_ENV || 'development'}.local`});

// // export const {
// // PORT,
// // BASE_URL,
// // NODE_ENV,
// // DB_URI,
// // JWT_SECRET,
// // JWT_EXPIRES_IN,
// // TMDB_BASE_URL,
// // TMDB_KEY
// // } = process.env;

// import { config } from 'dotenv';

// // Choose env file based on NODE_ENV
// let envFile = ".env.development.local"; // default

// if (process.env.NODE_ENV === "production") {
// envFile = ".env.production.local";
// } else if (process.env.NODE_ENV === "test") {
// envFile = ".env.test.local";
// }

// config({ path: envFile });

// export const {
// PORT,
// BASE_URL,
// NODE_ENV,
// DB_URI,
// JWT_SECRET,
// JWT_EXPIRES_IN,
// TMDB_BASE_URL,
// TMDB_KEY
// } = process.env;


import { config } from 'dotenv';
const env = process.env.NODE_ENV || "development";
config({ path: `.env.${env}.local` });
Expand Down
2 changes: 1 addition & 1 deletion server/controllers/history.controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import mongoose from "mongoose";
import History from "../models/history.model.js";
import WatchList from "../models/watchList.model.js";
import { getOrCreateMovie } from "../utils/movie.utils.js";
import { getOrCreateMovie } from "../services/movie.service.js";

/**
* Get current user's watched movies
Expand Down
21 changes: 21 additions & 0 deletions server/controllers/trending.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import TrendingMovies from "../models/trending.model.js";

export const getTrendingMovies = async (req, res) => {
try {
const today = new Date().toISOString().split("T")[0];

let trending = await TrendingMovies.findOne({ date: today })
.populate("movies");

// 🔥 fallback if today not available
if (!trending) {
trending = await TrendingMovies.findOne()
.sort({ createdAt: -1 })
.populate("movies");
}

return res.status(200).json(trending?.movies || []);
} catch (err) {
return res.status(500).json({ message: err.message });
}
};
Loading
Loading