Skip to content
Merged

Feat #28

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
5 changes: 5 additions & 0 deletions client/src/api/history.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export const getHistory = async (userId) => {
return res.data;
}

export const getMovieHistory = async (movieId) => {
const res = await axiosInstance.get(`/history/movie/${movieId}`);
return res.data;
}

export const getHistoryBanner = async () => {
const res = await axiosInstance.get('/history');
return res.data;
Expand Down
6 changes: 6 additions & 0 deletions client/src/api/watchList.api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import axiosInstance from "./axiosInstance"

export const getIsMovieWatchListed = async (movieId) => {
const res = await axiosInstance.get(`/watchlist/movie/${movieId}`);
return res.data;
}
99 changes: 83 additions & 16 deletions client/src/components/Navbar.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import useUserStore from "../store/userStore";
import Modal from "./ui/Modal";
import {
Expand All @@ -9,32 +9,69 @@ import {
TicketIcon
} from "@heroicons/react/24/outline";
import SearchBar from "./ui/SearchBar";
import Dropdown from "./ui/Dropdown";
import { ClockIcon } from "lucide-react";

export default function Navbar() {
const [open, setOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { user, logout } = useUserStore();
const location = useLocation();
const navigate = useNavigate();

const searchWrapperRef = useRef(null);

useEffect(() => {
const handleClickOutside = (event) => {
if (searchWrapperRef.current && !searchWrapperRef.current.contains(event.target)) {
setIsDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);

const getUserInitials = () => {
const userData = user?.user || user;
if (!userData?.firstName) return "U";
return (userData.firstName[0] + (userData.lastName?.[0] || "")).toUpperCase();
};

const navigate = useNavigate();
// Save search query to localStorage
const saveSearchQuery = (query) => {
if (!query.trim()) return;
const key = "searchHistory";
let history = JSON.parse(localStorage.getItem(key)) || [];

// Remove duplicate if exists
history = history.filter(item => item.query !== query);

history.unshift({ query, timeStamp: Date.now() });

if (history.length > 5) history.pop();

localStorage.setItem(key, JSON.stringify(history));
};

// Handle search
const handleSearch = (query) => {
if (!query.trim()) return;

saveSearchQuery(query);

navigate(`/search?q=${query}`);
setIsDropdownOpen(false);
};

const isActive = (path) => location.pathname === path;

// Get search history
const searchHistory = JSON.parse(localStorage.getItem("searchHistory")) || [];

return (
<nav className="fixed top-0 left-0 right-0 z-50 flex justify-center p-4 pointer-events-none">
{/* GLASS CONTAINER */}
<div className="w-full max-w-6xl bg-white/40 backdrop-blur-xl border border-white/80 shadow-[0_8px_32px_0_rgba(0,0,0,0.1)] rounded-3xl px-4 md:px-8 py-2 flex justify-between items-center pointer-events-auto transition-all duration-500">

{/* LOGO */}
<Link to="/" className="flex items-center gap-2.5 group shrink-0">
<div className="w-9 h-9 bg-stone-950 rounded-xl flex items-center justify-center text-amber-500 group-hover:rotate-12 transition-all duration-300 shadow-lg shadow-amber-900/20">
<FilmIcon className="size-5" />
Expand All @@ -44,19 +81,53 @@ export default function Navbar() {
</span>
</Link>

{/* CENTER NAVIGATION & SEARCH */}
<div className="flex items-center flex-1 max-w-2xl px-6 gap-2">
<div className="hidden md:flex items-center gap-1 bg-stone-100/50 p-1 rounded-2xl border border-stone-200/50 ">
<NavLink to="/" active={isActive('/')} icon={<HomeIcon className="size-4 text-amber-600" />} label="Home" />
<NavLink to="/movies" active={isActive('/movies')} icon={<TicketIcon className="size-4 text-amber-600" />} label="Movies" />
</div>

<div className="flex-1">
<SearchBar onSearch={handleSearch} />
{/* SEARCH BAR + DROPDOWN */}
<div ref={searchWrapperRef} className="relative w-full max-w-md mx-auto">
<SearchBar
onSearch={handleSearch}
onFocus={() => setIsDropdownOpen(true)}
/>

{/* We move the dropdown styles here or into the component */}
<Dropdown open={isDropdownOpen}>
<div className="w-full min-w-75 md:min-w-112.5 p-2 bg-white/90 backdrop-blur-xl">
<div className="px-3 py-2 border-b border-stone-100 mb-2">
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400">
Recent Searches
</span>
</div>

{searchHistory.length > 0 ? (
<div className="flex flex-col gap-1">
{searchHistory.map((item, index) => (
<button
key={index}
onClick={() => handleSearch(item.query)}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-stone-100 transition-all w-full text-left group"
>
<div className="p-2 rounded-lg bg-stone-50 group-hover:bg-white transition-colors">
<ClockIcon className="size-4 text-stone-400" />
</div>
<span className="font-medium text-stone-700">{item.query}</span>
</button>
))}
</div>
) : (
<div className="py-8 text-center">
<p className="text-sm text-stone-400 font-medium">No recent searches found</p>
</div>
)}
</div>
</Dropdown>
</div>
</div>

{/* ACTIONS */}
<div className="flex items-center gap-3 md:gap-5 shrink-0">
{user ? (
<>
Expand All @@ -83,7 +154,7 @@ export default function Navbar() {
</Link>

<button
onClick={() => setOpen(true)}
onClick={() => setIsModalOpen(true)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-stone-400 hover:text-red-600 hover:bg-red-50 hover:shadow-inner transition-all duration-300"
>
<ArrowRightStartOnRectangleIcon className="size-5" />
Expand All @@ -100,23 +171,20 @@ export default function Navbar() {
</div>
</div>

<Modal open={open} setOpen={setOpen}>
<Modal open={isModalOpen} setOpen={setIsModalOpen}>
<div className="p-8 md:p-12">
<div className="flex flex-col items-center text-center">
{/* Icon Container */}
<div className="size-16 mb-6 flex items-center justify-center rounded-2xl bg-red-50 text-red-600 border border-red-100">
<ArrowRightStartOnRectangleIcon className="size-8" />
</div>

{/* Text Content */}
<h3 className="text-2xl font-bold text-stone-900 tracking-tight">
Confirm Logout
</h3>
<p className="mt-3 text-stone-500 max-w-sm leading-relaxed">
Are you sure you want to logout? You'll need to sign back in to review movies.
</p>

{/* Action Buttons */}
<div className="mt-10 flex flex-col sm:flex-row gap-3 w-full max-w-md z-200">
<button
onClick={logout}
Expand All @@ -125,7 +193,7 @@ export default function Navbar() {
Logout Now
</button>
<button
onClick={() => setOpen(false)}
onClick={() => setIsModalOpen(false)}
className="flex-1 px-6 py-4 rounded-2xl bg-stone-100 hover:bg-stone-200 text-stone-600 font-bold transition-all active:scale-95"
>
Stay Signed In
Expand All @@ -138,7 +206,6 @@ export default function Navbar() {
);
}

// Sub-component for cleaner nav links
function NavLink({ to, active, icon, label }) {
return (
<Link
Expand Down
19 changes: 19 additions & 0 deletions client/src/components/ui/Dropdown.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { motion, AnimatePresence } from "framer-motion";

export default function Dropdown({ open, children }) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.25 }}
className="absolute top-full right-0 mt-2 bg-white shadow-md rounded-xl p-1 z-50 border-2 border-stone-100"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
8 changes: 6 additions & 2 deletions client/src/components/ui/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { AnimatePresence, motion } from "framer-motion";

export default function SearchBar({
placeholder = "Search...",
onSearch
onSearch,
onFocus
}) {
const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false);
Expand Down Expand Up @@ -46,7 +47,10 @@ export default function SearchBar({
type="text"
placeholder={placeholder}
value={query}
onFocus={() => setIsFocused(true)}
onFocus={() => {
setIsFocused(true);
onFocus?.();
}}
onBlur={() => setIsFocused(false)}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
Expand Down
12 changes: 10 additions & 2 deletions client/src/pages/MoviePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { getMovieById } from "../api/movie.api";
import Loader from "../components/ui/Loader";
import { getMovieHistory } from "../api/history.api";
import { getIsMovieWatchListed } from "../api/watchList.api";

export default function MoviePage() {
const [movieData, setMovieData] = useState(null);
Expand All @@ -16,8 +18,14 @@ export default function MoviePage() {
if (!movieId) return;
const fetchMovie = async () => {
try {
const res = await getMovieById(movieId);
setMovieData(res.movie || res);
const res = await Promise.all([
getMovieById(movieId),
getMovieHistory(movieId),
getIsMovieWatchListed(movieId)
]);
setMovieData(res[0]);
setWatched(res[1].data != null);
setInList(res[2].data.watchListed);
} catch (error) {
console.error("Error fetching movie: ", error);
} finally {
Expand Down
41 changes: 31 additions & 10 deletions client/src/pages/Profile.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { motion, AnimatePresence } from "framer-motion";

import bbBg from "../assets/bb-bg.jpg";
import defaultPfp from "../assets/default-pfp.jpg";
Expand All @@ -11,11 +11,13 @@ import Loader from "../components/ui/Loader";
import Modal from "../components/ui/Modal";
import SearchBar from "../components/ui/SearchBar";
import { getHistoryBanner } from "../api/history.api";
import Dropdown from "../components/ui/Dropdown";

export default function Profile() {
const [profileData, setProfileData] = useState(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [banners, setBanners] = useState([]);
const [selectedBackdrop, setSelectedBackdrop] = useState(null);
const user = useUserStore((state) => state.user);
Expand All @@ -37,7 +39,7 @@ export default function Profile() {
}, [userId]);

const handleChangeCover = async () => {
setOpen(true);
setIsModalOpen(true);
try {
const bannerData = await getHistoryBanner();
const movies = bannerData.data.map(item => item.movieId);
Expand All @@ -58,7 +60,7 @@ export default function Profile() {
cover: selectedBackdrop
}
}));
setOpen(false);
setIsModalOpen(false);
setSelectedBackdrop(null);
} catch (err) {
console.error("Failed to set profile cover", err);
Expand All @@ -77,7 +79,7 @@ export default function Profile() {
initial={{ scale: 1.1, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8 }}
src={`${import.meta.env.VITE_TMDB_BACKDROP_BASE_URL}${profileData.user.cover}` || bbBg}
src={profileData.user.cover ? `${import.meta.env.VITE_TMDB_BACKDROP_BASE_URL}${profileData.user.cover}` : bbBg}
alt="Cover"
className="w-full h-full object-cover"
draggable="false"
Expand Down Expand Up @@ -150,9 +152,28 @@ export default function Profile() {
</button>
)}

<button className="w-14 h-14 flex items-center justify-center bg-white border border-stone-200 rounded-2xl text-stone-400 hover:text-stone-900 transition-all shadow-sm active:scale-95">
<span className="material-symbols-outlined text-2xl">more_horiz</span>
</button>
{/* DROPDOWN CONTAINER */}
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-14 h-14 flex items-center justify-center bg-white border border-stone-200 rounded-2xl text-stone-400 hover:text-stone-900 transition-all shadow-sm active:scale-95"
>
<span className="material-symbols-outlined text-2xl">more_horiz</span>
</button>

<Dropdown open={isDropdownOpen} setOpen={setIsDropdownOpen}>
<div className="p-2 min-w-40">
<button className="w-full text-left p-2 text-sm font-bold text-stone-600 hover:bg-stone-50 rounded-lg transition-colors uppercase tracking-wider">
Share Profile
</button>
{isMyProfile && (
<button className="w-full text-left p-2 text-sm font-bold text-red-500 hover:bg-red-50 rounded-lg transition-colors uppercase tracking-wider">
Settings
</button>
)}
</div>
</Dropdown>
</div>
</motion.div>
</div>
</div>
Expand Down Expand Up @@ -189,7 +210,7 @@ export default function Profile() {
</div>

{/* Modal for Cover Selection */}
<Modal open={open} setOpen={setOpen}>
<Modal open={isModalOpen} setOpen={setIsModalOpen}>
<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">
Expand Down Expand Up @@ -265,7 +286,7 @@ export default function Profile() {
{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
onClick={() => setOpen(false)}
onClick={() => setIsModalOpen(false)}
className="px-6 py-3 bg-stone-200 rounded-xl font-bold hover:bg-stone-300 transition-all"
>
Cancel
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/SearchResultPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function SearchResultPage() {
{/* 1. SEARCH HEADER */}
<header className="mb-12">
<div className="flex items-center gap-3 mb-4">
<div className="h-[2px] w-8 bg-amber-500/40" />
<div className="h-0.5 w-8 bg-amber-500/40" />
<h5 className="text-[10px] font-black uppercase tracking-[0.4em] text-stone-400">Search Results</h5>
</div>
<h1 className="text-4xl md:text-6xl font-black text-stone-900 tracking-tighter">
Expand Down
Loading
Loading