Skip to content
Merged

Feat #33

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 @@ -10,6 +10,7 @@ import MoviePage from "./pages/MoviePage";
import Navbar from "./components/Navbar";
import TmdbMovie from "./pages/MoviesPage";
import SearchResultPage from "./pages/SearchResultPage";
import EditProfile from "./pages/EditProfile";

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

<Route
path="/edit-profile"
element={
token && !isExpired
? <EditProfile />
: <Navigate to="/login" />
}
/>

</Routes>
</BrowserRouter>
);
Expand Down
11 changes: 10 additions & 1 deletion client/src/api/user.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@ export const getProfile = async (userId) => {
};

export const setProfileCover = async (backdrop) => {
const res = await axiosInstance.put('/user/me', {
const res = await axiosInstance.put('/user/me/edit', {
cover: backdrop
});

return res.data;
}

export const editProfileData = async (pfp, about) => {
const res = await axiosInstance.put('/user/me/edit', {
pfp,
about
});

return res.data;
}

export const searchUsers = async (query) => {
const res = await axiosInstance.get(`/user/search?q=${query}`);
return res.data;
Expand Down
5 changes: 5 additions & 0 deletions client/src/api/userFollows.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ import axiosInstance from "./axiosInstance";
export const followUser = async (followingId) => {
const res = await axiosInstance.post('/me/follow', { followingId });
return res;
}

export const unfollowUser = async (followingId) => {
const res = await axiosInstance.post('/me/unfollow', { followingId });
return res;
}
4 changes: 1 addition & 3 deletions client/src/components/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ClockIcon } from "lucide-react";
export default function Navbar() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { user, logout } = useUserStore();
const { user, setUser, logout } = useUserStore();
const location = useLocation();
const navigate = useNavigate();

Expand Down Expand Up @@ -87,14 +87,12 @@ export default function Navbar() {
<NavLink to="/movies" active={isActive('/movies')} icon={<TicketIcon className="size-4 text-amber-600" />} label="Movies" />
</div>

{/* 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">
Expand Down
153 changes: 153 additions & 0 deletions client/src/pages/EditProfile.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { useState, useRef } from "react";
import { motion } from "framer-motion";
import useUserStore from "../store/userStore";
import defaultPfp from "../assets/default-pfp.jpg";
import { editProfileData } from "../api/user.api";
import axios from "axios";

export default function EditProfile() {
const { user, setUser } = useUserStore();
const [loading, setLoading] = useState(false);
const [about, setAbout] = useState(user?.user?.about || "");
const [previewPfp, setPreviewPfp] = useState(user?.user?.pfp || defaultPfp);
const [selectedPfpFile, setSelectedPfpFile] = useState(null);

const fileInputRef = useRef(null);

const handleSave = async (e) => {
e.preventDefault();
setLoading(true);
try {
const formData = new FormData();
formData.append("file", selectedPfpFile);
formData.append("upload_preset", import.meta.env.VITE_CLOUDINARY_PRESET);
formData.append("folder", import.meta.env.VITE_CLOUDINARY_ASSET_FOLDER);

const cloudName = import.meta.env.VITE_CLOUDINARY_CLOUD;

const cloudinaryRes = await axios.post(
`https://api.cloudinary.com/v1_1/${cloudName}/image/upload`,
formData
);

const res = await editProfileData(cloudinaryRes.data.secure_url, about);
if (res.success) {
setUser({ ...user, user: res.user });
}
setLoading(false);
} catch (err) {
console.error(err);
setLoading(false);
}
};

return (
<main className="min-h-screen bg-stone-50 py-12 px-6 pt-36">
<div className="max-w-3xl mx-auto">
{/* Header */}
<header className="mb-12">
<div className="flex items-center gap-2 mb-2">
<div className="h-1 w-6 bg-amber-500 rounded-full" />
<span className="text-[10px] font-black tracking-[0.4em] text-stone-400 uppercase">Public Persona</span>
</div>
<h1 className="text-4xl font-black text-stone-900 tracking-tighter">
Edit <span className="text-stone-400 font-light italic">Profile</span>
</h1>
</header>

<form onSubmit={handleSave} className="space-y-10">

{/* PFP Upload Section */}
<section className="bg-white p-8 rounded-[2.5rem] border border-stone-200 shadow-sm">
<h3 className="text-xs font-black uppercase tracking-widest text-stone-500 mb-6">Profile Picture</h3>
<div className="flex flex-col md:flex-row items-center gap-8">
<div className="relative group">
<div className="w-32 h-32 rounded-4xl overflow-hidden border-4 border-amber-50 shadow-xl">
<img
src={previewPfp}
alt="Preview"
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
<button
type="button"
onClick={() => fileInputRef.current.click()}
className="absolute -bottom-2 -right-2 w-10 h-10 bg-stone-900 text-white rounded-xl flex items-center justify-center hover:bg-amber-500 transition-colors shadow-lg"
>
<span className="material-symbols-outlined text-sm">photo_camera</span>
</button>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={(e) => {
const file = e.target.files[0];
if (file) {
setSelectedPfpFile(file);
setPreviewPfp(URL.createObjectURL(file));
}
}}
/>
</div>
<div className="flex-1 text-center md:text-left">
<p className="text-stone-900 font-bold text-lg">Change Avatar</p>
<p className="text-stone-400 text-sm leading-relaxed">
Upload a high-quality cinematic square image. <br />
Supports JPG, PNG or WebP.
</p>
</div>
</div>
</section>

{/* Bio / About Section */}
<section className="bg-white p-8 rounded-[2.5rem] border border-stone-200 shadow-sm">
<h3 className="text-xs font-black uppercase tracking-widest text-stone-500 mb-6">Director's Statement</h3>
<div className="relative">
<textarea
value={about}
onChange={(e) => setAbout(e.target.value)}
rows="5"
placeholder="Write a short bio about your cinematic journey..."
className="w-full p-6 bg-stone-50 border-2 border-transparent focus:border-amber-400/20 focus:bg-white rounded-3xl outline-none transition-all duration-300 text-stone-800 font-serif italic text-lg leading-relaxed resize-none"
/>
<div className="absolute right-4 bottom-4 text-[10px] font-black text-stone-300 uppercase tracking-widest">
{about.length} / 250
</div>
</div>
</section>

{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-4">
<button
type="button"
className="px-8 py-4 text-stone-400 font-bold hover:text-stone-900 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className={`px-12 py-4 bg-stone-900 text-white rounded-2xl font-black transition-all shadow-xl active:scale-95 flex items-center gap-3
${loading ? 'opacity-70 cursor-not-allowed' : 'hover:bg-amber-500 hover:text-stone-900'}
`}
>
{loading ? 'Saving Changes...' : 'Update Profile'}
{!loading && <span className="material-symbols-outlined text-sm">check_circle</span>}
</button>
</div>
</form>

{/* Settings Link */}
<div className="mt-12 pt-8 border-t border-stone-200 text-center">
<p className="text-stone-400 text-sm font-medium">
Looking to change your name or password?
<a href="/settings" className="ml-2 text-amber-600 font-black uppercase tracking-widest text-[10px] hover:underline">
Go to Account Settings
</a>
</p>
</div>
</div>
</main>
);
}
Loading
Loading