Skip to content
Merged

Feat #30

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
25 changes: 25 additions & 0 deletions client/src/api/commments.api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import axiosInstance from "./axiosInstance"

export const getAllComments = async (reviewId) => {
const res = await axiosInstance.get(`/${reviewId}/comments`);
return res.data;
};

export const postComment = async (reviewId, comment) => {
const res = axiosInstance.post(`/${reviewId}/comments`, {
comment: comment
});
return res;
};

export const updateComment = async (reviewId, commentId, updatedComment) => {
const res = axiosInstance.patch(`/${reviewId}/comments/${commentId}`, {
comment: updatedComment
});
return res;
};

export const deleteComment = async (reviewId, commentId) => {
const res = axiosInstance.delete(`/${reviewId}/comments/${commentId}`);
return res;
};
6 changes: 6 additions & 0 deletions client/src/api/userFollows.api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import axiosInstance from "./axiosInstance";

export const followUser = async (followingId) => {
const res = await axiosInstance.post('/me/follow', { followingId });
return res;
}
4 changes: 2 additions & 2 deletions client/src/components/Tabs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import { motion, AnimatePresence } from "framer-motion";
import ReviewCard from "./ui/ReviewCard";
import defaultPfp from "../assets/default-pfp.jpg";
import { getMyReviews, getUserReviews } from "../api/reviews.api";
import MovieCard from "./ui/MovieCard";
import HistoryTab from "./HistoryTab";
import Loader from "./ui/Loader";

export default function Tabs({ profileData, isMyProfile }) {
export default function Tabs({ profileData, isMyProfile, onReplyClick }) {
const [activeTab, setActiveTab] = useState("reviews");
const [tabContent, setTabContent] = useState(null);
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -111,6 +110,7 @@ export default function Tabs({ profileData, isMyProfile }) {
reviewText={r?.review}
reviewLikes={r?.likedBy}
rating={r?.rating}
onReplyClick={() => onReplyClick(r)}
/>
))
) : (
Expand Down
6 changes: 4 additions & 2 deletions client/src/components/ui/ReviewCard.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useState } from "react";
import axios from "axios";
import useUserStore from "../../store/userStore";
import { toggleLikeReview } from "../../api/reviews.api";

Expand All @@ -19,6 +18,7 @@ export default function ReviewCard({
releaseYear,
imdbRating,
genres,
onReplyClick
}) {
const [isLiked, setIsLiked] = useState(
Array.isArray(reviewLikes)
Expand Down Expand Up @@ -179,7 +179,9 @@ export default function ReviewCard({
{formattedLikes}
</button>

<button className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg border border-gray-200 bg-white text-[13px] font-semibold text-gray-600 cursor-pointer">
<button
onClick={onReplyClick}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg border border-gray-200 bg-white text-[13px] font-semibold text-gray-600 cursor-pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
Expand Down
196 changes: 176 additions & 20 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, AnimatePresence } from "framer-motion";
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";

import bbBg from "../assets/bb-bg.jpg";
import defaultPfp from "../assets/default-pfp.jpg";
Expand All @@ -12,23 +12,32 @@ import Modal from "../components/ui/Modal";
import SearchBar from "../components/ui/SearchBar";
import { getHistoryBanner } from "../api/history.api";
import Dropdown from "../components/ui/Dropdown";
import { followUser } from "../api/userFollows.api";
import { getAllComments, postComment, updateComment } from "../api/commments.api";

export default function Profile() {
const [profileData, setProfileData] = useState(null);
const [following, setFollowing] = useState(false);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isChangeCoverModalOpen, setIsChangeCoverModalOpen] = useState(false);
const [isReplyModalOpen, setIsReplyModalOpen] = useState(false);
const [selectedReview, setSelectedReview] = useState(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [comments, setComments] = useState([]);
const [selectedComment, setSelectedComment] = useState(null);
const [banners, setBanners] = useState([]);
const [selectedBackdrop, setSelectedBackdrop] = useState(null);
const user = useUserStore((state) => state.user);
const { userId } = useParams();
const commentInputRef = useRef(null);

// Fetch profile data
useEffect(() => {
const fetchProfile = async () => {
try {
const data = await getProfile(userId);
setProfileData(data);
const res = await getProfile(userId);
setProfileData(res);
console.log(res);
} catch (err) {
console.error("Failed to fetch profile", err);
} finally {
Expand All @@ -38,8 +47,54 @@ export default function Profile() {
fetchProfile();
}, [userId]);

const handleFollow = async () => {
try {
const res = await followUser(profileData.user._id);
if (res.status === 200) {
setFollowing(true);
}
} catch (error) {
console.error("Failed to follow user", error);
}
}

const handleOpenReplyModal = async (review) => {
setSelectedReview(review);
setIsReplyModalOpen(true);
try {
const res = await getAllComments(review._id);
setComments(res.comments);
} catch (error) {
console.error("Failed to fetch comments", error);
}
}

const handlePostComment = async () => {
console.log(commentInputRef.current.value);
try {
const res = await postComment(selectedReview._id, commentInputRef.current.value);
if (res.data.success) {
console.log("Comment posted successfully");
}
} catch (error) {
console.error("Failed to post comment", error);
}
}

const handleUpdateComment = async () => {
try {
const res = await updateComment(selectedReview._id, selectedComment, "Updated comment content");
setSelectedComment(null);
if (res.data.success) {
console.log("Comment updated successfully");
}
} catch (error) {
console.error("Failed to update comment", error);
}
}

const handleChangeCover = async () => {
setIsModalOpen(true);
setIsChangeCoverModalOpen(true);
try {
const bannerData = await getHistoryBanner();
const movies = bannerData.data.map(item => item.movieId);
Expand All @@ -60,7 +115,7 @@ export default function Profile() {
cover: selectedBackdrop
}
}));
setIsModalOpen(false);
setIsChangeCoverModalOpen(false);
setSelectedBackdrop(null);
} catch (err) {
console.error("Failed to set profile cover", err);
Expand Down Expand Up @@ -143,24 +198,32 @@ export default function Profile() {
className="flex items-center gap-3"
>
{!isMyProfile ? (
<button className="px-10 py-4 bg-stone-900 text-white rounded-2xl font-bold text-sm hover:bg-stone-800 transition-all shadow-lg active:scale-95">
Follow
</button>
!following ? (
<button
onClick={handleFollow}
className="px-10 py-4 bg-stone-900 text-white rounded-2xl font-bold text-sm hover:bg-stone-800 transition-all shadow-lg active:scale-95">
Follow
</button>
) : (
<button className="px-10 py-4 bg-white border border-stone-200 text-stone-600 rounded-2xl font-bold text-sm hover:bg-stone-50 transition-all shadow-sm active:scale-95">
Unfollow
</button>
)
) : (
<button className="px-10 py-4 bg-white border border-stone-200 text-stone-600 rounded-2xl font-bold text-sm hover:bg-stone-50 transition-all shadow-sm active:scale-95">
Edit Profile
</button>
)}

{/* DROPDOWN CONTAINER */}
{/* Dropdown */}
<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">
Expand Down Expand Up @@ -204,13 +267,111 @@ export default function Profile() {
className="pb-24"
>
<div className="bg-white rounded-[2.5rem] border border-stone-200 shadow-sm overflow-hidden min-h-125">
<Tabs profileData={profileData} isMyProfile={isMyProfile} />
<Tabs
profileData={profileData}
isMyProfile={isMyProfile}
onReplyClick={handleOpenReplyModal}
/>
</div>
</motion.section>
</div>

{/* Comments Modal */}
<Modal open={isReplyModalOpen} setOpen={setIsReplyModalOpen}>
<div className="flex flex-col h-[80vh] max-h-175">

<div className="p-6 border-b border-stone-100 bg-stone-50/50">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-amber-600 mb-1">
Discussion
</h3>
<h2 className="text-2xl font-black tracking-tight text-stone-900 leading-none">
Responses
</h2>
</div>

<div className="flex-1 overflow-y-auto p-6 space-y-6 scrollbar-hide">
{comments?.length > 0 ? (
comments.map((c) => (
<div key={c._id} className="flex gap-4 group">
<img
src={c.userId?.pfp || defaultPfp}
className="w-8 h-8 rounded-full object-cover shrink-0 shadow-sm"
/>
<div className="flex-1">
<div className="bg-stone-100 rounded-2xl rounded-tl-none px-4 py-3">
<div className="flex items-center gap-2">
<p className="text-xs font-black text-stone-900">{profileData?.user.firstName} {profileData?.user.lastName}</p>
<span className="text-[9px] text-stone-400 ml-2">{new Date(c.createdAt).toLocaleString()}</span>

{isMyProfile &&
<div className="relative">
<button
onClick={() => setSelectedComment(selectedComment === c._id ? null : c._id)}
className="w-5 h-5 flex items-center justify-center text-stone-400 hover:text-stone-900 transition-all active:scale-95"
>
<span className="material-symbols-outlined text-2xl">more_horiz</span>
</button>

<Dropdown open={selectedComment === c._id} setOpen={setSelectedComment}>
<div className="p-2 min-w-40">
<button
onClick={() => handleUpdateComment}
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">
Edit Comment
</button>
<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">
Delete Comment
</button>
</div>
</Dropdown>
</div>
}
</div>
<p className="text-sm text-stone-600 mt-1 leading-relaxed">{c.comment}</p>
</div>
<div className="flex gap-4 mt-2 ml-2">
<button className="text-[9px] font-black uppercase tracking-widest text-stone-400 hover:text-amber-600 transition-colors">Like</button>
<button className="text-[9px] font-black uppercase tracking-widest text-stone-400 hover:text-amber-600 transition-colors">Reply</button>
</div>
</div>
</div>
))
) : (
<div className="py-10 text-center">
<p className="text-stone-400 font-serif italic text-sm">No thoughts shared yet. Be the first to break the silence.</p>
</div>
)}
</div>

<div className="p-4 bg-white border-t border-stone-100 shadow-[0_-10px_20px_rgba(0,0,0,0.02)]">
<div className="relative flex items-center gap-3 bg-stone-100 rounded-2xl p-2 focus-within:bg-white focus-within:ring-2 focus-within:ring-amber-500/20 transition-all">
<img
src={user?.user?.pfp || defaultPfp}
className="w-8 h-8 rounded-full object-cover ml-1 shadow-sm"
/>
<textarea
ref={commentInputRef}
placeholder="Add to the conversation..."
className="flex-1 bg-transparent border-none outline-none focus:ring-0 text-sm py-2 resize-none max-h-32 text-stone-800 placeholder-stone-400 font-medium"
rows={1}
onInput={(e) => {
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
/>
<button
onClick={handlePostComment}
className="px-4 py-2 bg-stone-900 text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-amber-600 active:scale-95 transition-all shadow-lg"
>
Post
</button>
</div>
</div>
</div>
</Modal>

{/* Modal for Cover Selection */}
<Modal open={isModalOpen} setOpen={setIsModalOpen}>
<Modal open={isChangeCoverModalOpen} setOpen={setIsChangeCoverModalOpen}>
<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 @@ -262,11 +423,6 @@ export default function Profile() {
Click to Apply
</p>
</div>
<div className="size-10 rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity">
<svg className="size-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</div>
</motion.div>
Expand All @@ -286,7 +442,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={() => setIsModalOpen(false)}
onClick={() => setIsChangeCoverModalOpen(false)}
className="px-6 py-3 bg-stone-200 rounded-xl font-bold hover:bg-stone-300 transition-all"
>
Cancel
Expand Down
Loading
Loading