From 1828c3b14458c127219d4886ada84c7fa9627ed9 Mon Sep 17 00:00:00 2001 From: Avdhesh-Varshney Date: Sun, 4 Jan 2026 14:22:41 +0530 Subject: [PATCH 1/5] migrate 4 modules --- client/.vercelignore | 7 - client/src/App.tsx | 16 +- client/src/infra/rest/apis/project/index.ts | 2 +- client/src/modules/change-password/index.tsx | 64 +- .../src/modules/edit-profile/hooks/index.ts | 90 +++ client/src/modules/edit-profile/index.tsx | 510 +++++++++------ .../src/modules/edit-profile/states/index.ts | 4 + .../components/draft-projects.tsx | 185 ++++-- .../components/publish-projects.tsx | 371 ++++++----- .../modules/manage-projects/hooks/index.ts | 78 +++ client/src/modules/manage-projects/index.tsx | 262 ++++---- .../modules/manage-projects/states/index.ts | 16 + .../components/notificationCard.tsx | 595 +++++++----------- .../components/notificationCommentField.tsx | 36 +- .../src/modules/notification/hooks/index.ts | 61 ++ client/src/modules/notification/index.tsx | 169 ++--- .../src/modules/notification/states/index.ts | 11 + .../navbar/components/render-menu.tsx | 64 +- 18 files changed, 1448 insertions(+), 1093 deletions(-) create mode 100644 client/src/modules/edit-profile/hooks/index.ts create mode 100644 client/src/modules/edit-profile/states/index.ts create mode 100644 client/src/modules/notification/hooks/index.ts create mode 100644 client/src/modules/notification/states/index.ts diff --git a/client/.vercelignore b/client/.vercelignore index 051e59ff4..8538e9328 100644 --- a/client/.vercelignore +++ b/client/.vercelignore @@ -1,8 +1 @@ -projects/* server/* -client/src/modules/edit-profile/* -client/src/modules/manage-projects/* -client/src/modules/notification/* -src/modules/edit-profile/* -src/modules/manage-projects/* -src/modules/notification/* diff --git a/client/src/App.tsx b/client/src/App.tsx index b82e81a15..1748c6e69 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,10 +12,10 @@ import Search from './modules/search'; import Profile from './modules/profile'; import Project from './modules/project'; import Sidebar from './shared/components/organisms/sidebar'; -// import ChangePassword from "./modules/change-password"; -// import ManageProjects from "./modules/manage-projects"; -// import EditProfile from "./modules/edit-profile"; -// import Notifications from "./modules/notification"; +import ChangePassword from './modules/change-password'; +import ManageProjects from './modules/manage-projects'; +import EditProfile from './modules/edit-profile'; +import Notifications from './modules/notification'; function App() { useEffect(() => { @@ -35,12 +35,12 @@ function App() { } /> }> - {/* } /> */} - {/* } /> */} + } /> + } /> }> - {/* } /> */} - {/* } /> */} + } /> + } /> diff --git a/client/src/infra/rest/apis/project/index.ts b/client/src/infra/rest/apis/project/index.ts index d530a98f1..cc726f05b 100644 --- a/client/src/infra/rest/apis/project/index.ts +++ b/client/src/infra/rest/apis/project/index.ts @@ -75,7 +75,7 @@ export const userProjects = async ({ query, deletedDocCount, }: userProjectsPayload) => { - return get>( + return get>( `/api/project/user?is_draft=${is_draft}&query=${query}&page=${page}&deletedDocCount=${deletedDocCount}` ); }; diff --git a/client/src/modules/change-password/index.tsx b/client/src/modules/change-password/index.tsx index cce104472..3493121bb 100644 --- a/client/src/modules/change-password/index.tsx +++ b/client/src/modules/change-password/index.tsx @@ -1,17 +1,19 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { useNotifications } from '../../shared/hooks/use-notification'; import { passwordRegex } from '../../shared/utils/regex'; import { changePassword } from '../../infra/rest/apis/auth'; import InputBox from '../../shared/components/atoms/input-box'; -import { Box, Button, Typography, Stack } from '@mui/material'; +import { Box, Button, Stack, CircularProgress } from '@mui/material'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import VpnKeyOutlinedIcon from '@mui/icons-material/VpnKeyOutlined'; +import A2ZTypography from '../../shared/components/atoms/typography'; const ChangePassword = () => { const changePasswordForm = useRef(null); const { addNotification } = useNotifications(); + const [loading, setLoading] = useState(false); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!changePasswordForm.current) return; @@ -44,26 +46,29 @@ const ChangePassword = () => { return; } - e.currentTarget.setAttribute('disabled', 'true'); + setLoading(true); - changePassword({ - current_password: currentPassword, - new_password: newPassword, - }) - .then(() => { - e.currentTarget.removeAttribute('disabled'); - return addNotification({ - message: 'Password Updated!', - type: 'success', - }); - }) - .catch(({ response }) => { - e.currentTarget.removeAttribute('disabled'); - return addNotification({ - message: response.data.error, - type: 'error', - }); + try { + await changePassword({ + current_password: currentPassword, + new_password: newPassword, }); + addNotification({ + message: 'Password Updated!', + type: 'success', + }); + if (changePasswordForm.current) { + changePasswordForm.current.reset(); + } + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + addNotification({ + message: err.response?.data?.error || 'Failed to update password', + type: 'error', + }); + } finally { + setLoading(false); + } }; return ( @@ -75,14 +80,16 @@ const ChangePassword = () => { display: 'flex', flexDirection: 'column', gap: 4, - maxWidth: 400, + maxWidth: 500, width: '100%', - py: 6, + py: 4, }} > - - Change Password - + { type="password" placeholder="Current Password" icon={} + disabled={loading} /> { type="password" placeholder="New Password" icon={} + disabled={loading} /> ); diff --git a/client/src/modules/edit-profile/hooks/index.ts b/client/src/modules/edit-profile/hooks/index.ts new file mode 100644 index 000000000..f00b8c62c --- /dev/null +++ b/client/src/modules/edit-profile/hooks/index.ts @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; +import { useSetAtom, useAtomValue } from 'jotai'; +import { + userProfile, + updateProfile, + updateProfileImg, +} from '../../../infra/rest/apis/user'; +import { uploadImage } from '../../../infra/rest/apis/media'; +import { EditProfileAtom } from '../states'; +import { UserAtom } from '../../../infra/states/user'; + +const useEditProfile = () => { + const setProfile = useSetAtom(EditProfileAtom); + const setUser = useSetAtom(UserAtom); + const user = useAtomValue(UserAtom); + const profile = useAtomValue(EditProfileAtom); + + const fetchProfile = useCallback(async () => { + if (!user?.personal_info?.username) return; + + try { + const response = await userProfile(user.personal_info.username); + if (response.data) { + setProfile(response.data); + } + } catch (error) { + console.error('Error fetching profile:', error); + } + }, [user?.personal_info?.username, setProfile]); + + const updateProfileImage = useCallback( + async (imageFile: File) => { + const uploadResponse = await uploadImage(imageFile); + if (uploadResponse.data?.upload_url) { + const response = await updateProfileImg(uploadResponse.data.upload_url); + if (response.data && user) { + const updatedUser = { + ...user, + personal_info: { + ...user.personal_info, + profile_img: response.data.profile_img, + }, + }; + setUser(updatedUser); + return response.data.profile_img; + } + } + }, + [user, setUser] + ); + + const updateUserProfile = useCallback( + async (profileData: { + username: string; + bio: string; + social_links: { + youtube: string; + instagram: string; + facebook: string; + x: string; + github: string; + linkedin: string; + website: string; + }; + }) => { + const response = await updateProfile(profileData); + if (response.data && user) { + const updatedUser = { + ...user, + personal_info: { + ...user.personal_info, + username: response.data.username, + }, + }; + setUser(updatedUser); + } + return response; + }, + [user, setUser] + ); + + return { + fetchProfile, + updateProfileImage, + updateUserProfile, + profile, + }; +}; + +export default useEditProfile; diff --git a/client/src/modules/edit-profile/index.tsx b/client/src/modules/edit-profile/index.tsx index 9d5ab6b6a..76a9e7fdc 100644 --- a/client/src/modules/edit-profile/index.tsx +++ b/client/src/modules/edit-profile/index.tsx @@ -1,48 +1,56 @@ import { useEffect, useRef, useState } from 'react'; -import { useAtom } from 'jotai'; -import { UserAtom } from '../../infra/states/user'; -import { ProfileAtom } from '../profile/states'; import { useNotifications } from '../../shared/hooks/use-notification'; import { bioLimit } from './constants'; import { useAuth } from '../../shared/hooks/use-auth'; +import useEditProfile from './hooks'; +import { + Box, + Avatar, + Button, + TextField, + Typography, + Stack, + CircularProgress, +} from '@mui/material'; +import PhotoCameraIcon from '@mui/icons-material/PhotoCamera'; +import A2ZTypography from '../../shared/components/atoms/typography'; +import InputBox from '../../shared/components/atoms/input-box'; +import PersonIcon from '@mui/icons-material/Person'; +import AlternateEmailIcon from '@mui/icons-material/AlternateEmail'; +import YouTubeIcon from '@mui/icons-material/YouTube'; +import FacebookIcon from '@mui/icons-material/Facebook'; +import TwitterIcon from '@mui/icons-material/Twitter'; +import GitHubIcon from '@mui/icons-material/GitHub'; +import InstagramIcon from '@mui/icons-material/Instagram'; +import LanguageIcon from '@mui/icons-material/Language'; const EditProfile = () => { - const [user, setUser] = useAtom(UserAtom); - const [profile, setProfile] = useAtom(ProfileAtom); const { addNotification } = useNotifications(); const { isAuthenticated } = useAuth(); + const { fetchProfile, updateProfileImage, updateUserProfile, profile } = + useEditProfile(); - const profileImgEle = useRef(null); + const profileImgInputRef = useRef(null); const editProfileForm = useRef(null); - const [loading, setLoading] = useState(true); - const [charactersLeft, setCharactersLeft] = useState(bioLimit); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [saving, setSaving] = useState(false); + const [charactersLeft, setCharactersLeft] = useState(bioLimit); const [updatedProfileImg, setUpdatedProfileImg] = useState(null); - - const { - personal_info: { - fullname, - username: profile_username, - profile_img, - email, - bio, - }, - social_links, - } = profile; + const [previewImg, setPreviewImg] = useState(null); useEffect(() => { if (isAuthenticated()) { - getUserProfile(user?.personal_info.username) - .then(response => { - setProfile(response); - setLoading(false); - }) - .catch(({ response }) => { - console.log(response.data); - setLoading(false); - }); + fetchProfile().finally(() => setLoading(false)); } - }, [user?.personal_info.username, setProfile, isAuthenticated]); + }, [isAuthenticated, fetchProfile]); + + useEffect(() => { + if (profile?.personal_info?.bio) { + setCharactersLeft(bioLimit - profile.personal_info.bio.length); + } + }, [profile]); const handleCharacterChange = (e: React.ChangeEvent) => { setCharactersLeft(bioLimit - e.currentTarget.value.length); @@ -50,50 +58,39 @@ const EditProfile = () => { const handleImagePreview = (e: React.ChangeEvent) => { const img = e.currentTarget.files?.[0]; - if (profileImgEle.current && img) { - profileImgEle.current.src = URL.createObjectURL(img); + if (img) { + setPreviewImg(URL.createObjectURL(img)); setUpdatedProfileImg(img); } }; - const handleImageUpload = (e: React.MouseEvent) => { - e.preventDefault(); - - if (updatedProfileImg) { - e.currentTarget.setAttribute('disabled', 'true'); + const handleImageUpload = async () => { + if (!updatedProfileImg) return; - uploadImage(updatedProfileImg) - .then(url => { - if (url) { - uploadProfileImage(url) - .then(response => { - const newUser = { ...user, profile_img: response.profile_img }; - storeInSession('user', JSON.stringify(newUser)); - setUser(newUser); - - setUpdatedProfileImg(null); - e.currentTarget.removeAttribute('disabled'); - addNotification({ - message: 'Profile Image Updated', - type: 'success', - }); - }) - .catch(({ response }) => { - e.currentTarget.removeAttribute('disabled'); - addNotification({ - message: response.data.error, - type: 'error', - }); - }); - } - }) - .catch(err => { - console.log(err); - }); + setUploading(true); + try { + await updateProfileImage(updatedProfileImg); + addNotification({ + message: 'Profile Image Updated', + type: 'success', + }); + setUpdatedProfileImg(null); + setPreviewImg(null); + if (profileImgInputRef.current) { + profileImgInputRef.current.value = ''; + } + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + addNotification({ + message: err.response?.data?.error || 'Failed to update profile image', + type: 'error', + }); + } finally { + setUploading(false); } }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!editProfileForm.current) return; @@ -129,158 +126,253 @@ const EditProfile = () => { }); } - e.currentTarget.setAttribute('disabled', 'true'); - - updateProfile( - username, - bio as string, - youtube as string, - facebook as string, - twitter as string, - github as string, - instagram as string, - website as string - ) - .then(response => { - if (user.username != response.username) { - const newUserAuth = { ...user, username: response.username }; - storeInSession('user', JSON.stringify(newUserAuth)); - setUser(newUserAuth); - } - - e.currentTarget.removeAttribute('disabled'); - addNotification({ - message: 'Profile Updated', - type: 'success', - }); - }) - .catch(({ response }) => { - e.currentTarget.removeAttribute('disabled'); - addNotification({ - message: response.data.error, - type: 'error', - }); + setSaving(true); + + try { + await updateUserProfile({ + username, + bio: (bio as string) || '', + social_links: { + youtube: (youtube as string) || '', + facebook: (facebook as string) || '', + x: (twitter as string) || '', + github: (github as string) || '', + instagram: (instagram as string) || '', + linkedin: '', + website: (website as string) || '', + }, + }); + addNotification({ + message: 'Profile Updated', + type: 'success', + }); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + addNotification({ + message: err.response?.data?.error || 'Failed to update profile', + type: 'error', }); + } finally { + setSaving(false); + } }; - return ( - <> - {loading ? ( - - ) : ( -
-

Edit Profile

+ if (loading || !profile) { + return ( + + + + ); + } -
-
- + const { + personal_info: { fullname, username, profile_img, bio }, + social_links, + } = profile; - + const socialIcons: Record = { + youtube: , + facebook: , + twitter: , + github: , + instagram: , + website: , + }; - -
- -
-
-
- -
-
- -
-
- - - -

- Username will use to search user and will be visible to all - users -

- - - -

- {charactersLeft} characters Left -

- -

Add your social handle below

- -
- {( - Object.keys(social_links) as Array - ).map((key, i) => { - const link = social_links[key]; - - return ( - - ); - })} -
- - -
-
-
- )} - + {uploading ? : 'Upload'} + + + + + {/* Form Fields */} + + + + } + sx={{ flex: 1 }} + /> + + + + } + /> + + Username will be used to search user and will be visible to + all users + + + + + + + {charactersLeft} characters left + + + + + + Add your social handles below + + + {( + Object.keys(social_links) as Array< + keyof typeof social_links + > + ).map(key => { + const link = social_links[key] || ''; + return ( + } + sx={{ + flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)' }, + }} + /> + ); + })} + + + + + + + + + ); }; diff --git a/client/src/modules/edit-profile/states/index.ts b/client/src/modules/edit-profile/states/index.ts new file mode 100644 index 000000000..cf36f31ea --- /dev/null +++ b/client/src/modules/edit-profile/states/index.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; +import { getUserProfileResponse } from '../../../infra/rest/apis/user/typing'; + +export const EditProfileAtom = atom(null); diff --git a/client/src/modules/manage-projects/components/draft-projects.tsx b/client/src/modules/manage-projects/components/draft-projects.tsx index 731ac0977..e3f59921b 100644 --- a/client/src/modules/manage-projects/components/draft-projects.tsx +++ b/client/src/modules/manage-projects/components/draft-projects.tsx @@ -1,48 +1,153 @@ -const ManageDraftProjectPost = ({ project }: { project: Project }) => { - const { title, des } = project; - let { index = 0 } = project; +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useSetAtom } from 'jotai'; +import { deleteProjectById } from '../../../infra/rest/apis/project'; +import { useNotifications } from '../../../shared/hooks/use-notification'; +import { useAuth } from '../../../shared/hooks/use-auth'; +import { DraftProjectsAtom, ManageProjectsPaginationState } from '../states'; +import { + Box, + Card, + CardContent, + Typography, + Button, + Stack, +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { userProjectsResponse } from '../../../infra/rest/apis/project/typing'; - index++; +interface ManageDraftProjectPostProps { + project: userProjectsResponse; + index: number; +} + +const ManageDraftProjectPost = ({ + project, + index, +}: ManageDraftProjectPostProps) => { + const { _id, title, description } = project; + const setDraftProjects = useSetAtom(DraftProjectsAtom); + const { addNotification } = useNotifications(); + const { isAuthenticated } = useAuth(); + const [loading, setLoading] = useState(false); + + const handleDelete = async () => { + if (!_id || !isAuthenticated()) return; + + setLoading(true); + try { + await deleteProjectById(_id); + setDraftProjects((prev: ManageProjectsPaginationState | null) => { + if (!prev) return null; + + const newResults = prev.results.filter((_, i) => i !== index); + const newTotalDocs = prev.totalDocs - 1; + const newDeletedCount = (prev.deletedDocCount || 0) + 1; + + if (!newResults.length && newTotalDocs > 0) { + return null; + } + + return { + ...prev, + results: newResults, + totalDocs: newTotalDocs, + deletedDocCount: newDeletedCount, + }; + }); + addNotification({ + message: 'Draft deleted successfully', + type: 'success', + }); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + addNotification({ + message: err.response?.data?.error || 'Failed to delete draft', + type: 'error', + }); + } finally { + setLoading(false); + } + }; return ( -
-

- {index < 10 ? '0' + index : index} -

- -
-

{title}

- -

- {des?.length ? des : 'No Description'} -

- -
- + + + - Edit - + + {String(index + 1).padStart(2, '0')} + + - -
-
- -
- -
-
+ + + {title} + + + + {description || 'No description'} + + + + + + + + + + + ); }; diff --git a/client/src/modules/manage-projects/components/publish-projects.tsx b/client/src/modules/manage-projects/components/publish-projects.tsx index bcdbfa931..4da7d8cbf 100644 --- a/client/src/modules/manage-projects/components/publish-projects.tsx +++ b/client/src/modules/manage-projects/components/publish-projects.tsx @@ -1,182 +1,227 @@ import { Link } from 'react-router-dom'; -import { getDay } from '../../../../shared/utils/date'; +import { getDay } from '../../../shared/utils/date'; import { useState } from 'react'; -import { useAtom } from 'jotai'; -import { UserAtom } from '../../../../shared/states/user'; -import axios from 'axios'; - -import { SetStateAction } from 'react'; -import { AllProjectsData } from '../../../../infra/rest/typings'; - -interface ProjectStats { - total_likes: number; - total_comments: number; - total_reads: number; - [key: string]: number; // Allow dynamic key access +import { useSetAtom } from 'jotai'; +import { deleteProjectById } from '../../../infra/rest/apis/project'; +import { useNotifications } from '../../../shared/hooks/use-notification'; +import { useAuth } from '../../../shared/hooks/use-auth'; +import { + PublishedProjectsAtom, + ManageProjectsPaginationState, +} from '../states'; +import { + Box, + Card, + CardContent, + Typography, + Button, + Collapse, + Stack, + Divider, +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { userProjectsResponse } from '../../../infra/rest/apis/project/typing'; +import { PROJECT_ACTIVITY } from '../../../infra/rest/typings'; + +interface ManagePublishedProjectCardProps { + project: userProjectsResponse; + index: number; } -interface Project { - _id?: string; - project_id: string; - title: string; - des?: string; - banner?: string; - publishedAt: string; - activity?: ProjectStats; - index?: number; - setStateFunc?: (value: SetStateAction) => void; -} - -const ProjectStats = ({ stats }: { stats: ProjectStats }) => { +const ProjectStats = ({ activity }: { activity: PROJECT_ACTIVITY }) => { return ( -
- {Object.keys(stats).map((key, i) => { - return !key.includes('parent') ? ( -
-

- {stats[key].toLocaleString()} -

-

- {key.split('_')[1]} -

-
- ) : ( - '' - ); - })} -
+ } + sx={{ display: { xs: 'none', lg: 'flex' } }} + > + + + {activity.total_likes?.toLocaleString() || 0} + + + Likes + + + + + {activity.total_comments?.toLocaleString() || 0} + + + Comments + + + + + {activity.total_reads?.toLocaleString() || 0} + + + Reads + + + ); }; -const deleteProject = ( - project: Project, - access_token: string, - target: EventTarget | null -) => { - const { index, project_id, setStateFunc } = project; - - if (!(target instanceof HTMLElement)) return; - - target.setAttribute('disabled', 'true'); - - axios - .post( - import.meta.env.VITE_SERVER_DOMAIN + '/api/project/delete', - { project_id }, - { - headers: { - Authorization: `Bearer ${access_token}`, - }, - } - ) - .then(() => { - target.removeAttribute('disabled'); - - if (setStateFunc) { - setStateFunc((preVal: AllProjectsData | null) => { - if (!preVal) return null; - - const { deletedDocCount = 0, totalDocs = 0, results = [] } = preVal; - - if ( - typeof index === 'number' && - index >= 0 && - index < results.length - ) { - results.splice(index, 1); - } - - const newTotalDocs = totalDocs - 1; - const newDeletedCount = deletedDocCount + 1; - - if (!results.length && newTotalDocs > 0) { - return null; - } - - return { - ...preVal, - results, - totalDocs: newTotalDocs, - deletedDocCount: newDeletedCount, - }; - }); - } - }) - .catch(err => { - console.error('Error deleting project:', err); - target.removeAttribute('disabled'); - }); -}; - -const ManagePublishedProjectCard = ({ project }: { project: Project }) => { - const { banner, project_id, title, publishedAt, activity } = project; - - const [user] = useAtom(UserAtom); - const access_token = user.access_token || ''; - +const ManagePublishedProjectCard = ({ + project, + index, +}: ManagePublishedProjectCardProps) => { + const { _id, title, banner_url, publishedAt, activity } = project; + const setPublishedProjects = useSetAtom(PublishedProjectsAtom); + const { addNotification } = useNotifications(); + const { isAuthenticated } = useAuth(); const [showStat, setShowStat] = useState(false); + const [loading, setLoading] = useState(false); + + const handleDelete = async () => { + if (!_id || !isAuthenticated()) return; + + setLoading(true); + try { + await deleteProjectById(_id); + setPublishedProjects((prev: ManageProjectsPaginationState | null) => { + if (!prev) return null; + + const newResults = prev.results.filter((_, i) => i !== index); + const newTotalDocs = prev.totalDocs - 1; + const newDeletedCount = (prev.deletedDocCount || 0) + 1; + + if (!newResults.length && newTotalDocs > 0) { + return null; + } + + return { + ...prev, + results: newResults, + totalDocs: newTotalDocs, + deletedDocCount: newDeletedCount, + }; + }); + addNotification({ + message: 'Project deleted successfully', + type: 'success', + }); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + addNotification({ + message: err.response?.data?.error || 'Failed to delete project', + type: 'error', + }); + } finally { + setLoading(false); + } + }; return ( - <> -
- - -
-
- + + + {banner_url && ( + + )} + + + {title} - - -

Published on {getDay(publishedAt)}

-
+ -
- - Edit - - - + + Published on {getDay(publishedAt)} + - -
-
- -
- {activity && } -
-
- - {showStat ? ( -
- {activity && } -
- ) : ( - '' - )} - + + + + + + + + + {activity && ( + + + + )} + + + + + {activity && } + + + + ); }; diff --git a/client/src/modules/manage-projects/hooks/index.ts b/client/src/modules/manage-projects/hooks/index.ts index e69de29bb..df1607e25 100644 --- a/client/src/modules/manage-projects/hooks/index.ts +++ b/client/src/modules/manage-projects/hooks/index.ts @@ -0,0 +1,78 @@ +import { useCallback } from 'react'; +import { useSetAtom, useAtomValue } from 'jotai'; +import { + userProjects, + userProjectsCount, +} from '../../../infra/rest/apis/project'; +import { + PublishedProjectsAtom, + DraftProjectsAtom, + ManageProjectsPaginationState, +} from '../states'; +import { useAuth } from '../../../shared/hooks/use-auth'; + +const useManageProjects = () => { + const setPublishedProjects = useSetAtom(PublishedProjectsAtom); + const setDraftProjects = useSetAtom(DraftProjectsAtom); + const publishedProjects = useAtomValue(PublishedProjectsAtom); + const draftProjects = useAtomValue(DraftProjectsAtom); + const { isAuthenticated } = useAuth(); + + const fetchProjects = useCallback( + async (params: { + page: number; + is_draft: boolean; + query?: string; + deletedDocCount?: number; + }) => { + if (!isAuthenticated()) return; + + const { page, is_draft, query = '', deletedDocCount = 0 } = params; + + try { + const [projectsResponse, countResponse] = await Promise.all([ + userProjects({ is_draft, page, query, deletedDocCount }), + userProjectsCount({ is_draft, query, page: 1 }), + ]); + + if (projectsResponse.data && countResponse.data) { + const currentState = is_draft ? draftProjects : publishedProjects; + const existingResults = currentState?.results || []; + + const formattedData: ManageProjectsPaginationState = { + results: + page === 1 + ? projectsResponse.data + : [...existingResults, ...projectsResponse.data], + page, + totalDocs: countResponse.data.totalDocs || 0, + deletedDocCount, + }; + + if (is_draft) { + setDraftProjects(formattedData); + } else { + setPublishedProjects(formattedData); + } + } + } catch (error) { + console.error('Error fetching projects:', error); + } + }, + [ + isAuthenticated, + setPublishedProjects, + setDraftProjects, + publishedProjects, + draftProjects, + ] + ); + + return { + fetchProjects, + publishedProjects, + draftProjects, + }; +}; + +export default useManageProjects; diff --git a/client/src/modules/manage-projects/index.tsx b/client/src/modules/manage-projects/index.tsx index 80b68dd12..228c777e5 100644 --- a/client/src/modules/manage-projects/index.tsx +++ b/client/src/modules/manage-projects/index.tsx @@ -1,173 +1,157 @@ -import { useAtom, useAtomValue } from 'jotai'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import InPageNavigation from '../../shared/components/molecules/page-navigation'; import NoDataMessageBox from '../../shared/components/atoms/no-data-msg'; import ManagePublishedProjectCard from './components/publish-projects'; import ManageDraftProjectPost from './components/draft-projects'; +import useManageProjects from './hooks'; +import { Box, CircularProgress } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import InputBox from '../../shared/components/atoms/input-box'; +import A2ZTypography from '../../shared/components/atoms/typography'; +import Button from '../../shared/components/atoms/button'; +import { useSetAtom } from 'jotai'; +import { PublishedProjectsAtom, DraftProjectsAtom } from './states'; const ManageProjects = () => { - const [projects, setProjects] = useAtom(AllProjectsAtom); - const [drafts, setDrafts] = useAtom(DraftProjectAtom); - const user = useAtomValue(UserAtom); - - const activeTab = useSearchParams()[0].get('tab'); + const [searchParams] = useSearchParams(); + const activeTab = searchParams.get('tab'); const [query, setQuery] = useState(''); - - const getProjects = useCallback( - (params: Record) => { - const { page = 1, draft = false, deletedDocCount = 0 } = params; - - userWrittenProjects({ - page: page as number, - draft: draft as boolean, - query, - deletedDocCount: deletedDocCount as number, - }) - .then(async data => { - const formattedData = (await filterPaginationData({ - state: draft ? drafts : projects, - data: data.projects || [], - page: page as number, - countRoute: '/search-projects-count', - data_to_send: { - query, - tag: query, - author: user.username || '', - draft, - }, - })) as AllProjectsData; - - if (formattedData) { - if (draft) { - setDrafts(formattedData); - } else { - setProjects(formattedData); - } - } - }) - .catch(err => { - console.log(err); - }); - }, - [drafts, projects, query, setDrafts, setProjects, user.username] - ); + const { fetchProjects, publishedProjects, draftProjects } = + useManageProjects(); + const setPublishedProjects = useSetAtom(PublishedProjectsAtom); + const setDraftProjects = useSetAtom(DraftProjectsAtom); useEffect(() => { - if (user.access_token) { - if (projects === null) { - getProjects({ page: 1, draft: false }); - } - if (drafts === null) { - getProjects({ page: 1, draft: true }); - } + if (publishedProjects === null) { + fetchProjects({ page: 1, is_draft: false, query }); } - }, [user.access_token, projects, drafts, query, getProjects]); + if (draftProjects === null) { + fetchProjects({ page: 1, is_draft: true, query }); + } + }, [publishedProjects, draftProjects, fetchProjects, query]); const handleSearch = (e: React.KeyboardEvent) => { - const searchQuery = e.currentTarget.value; - setQuery(searchQuery); - - if (e.keyCode === 13 && searchQuery.length) { - setProjects(null); - setDrafts(null); + if (e.key === 'Enter' && query.length) { + setPublishedProjects(null); + setDraftProjects(null); } }; const handleChange = (e: React.ChangeEvent) => { - if (!e.currentTarget.value.length) { - setQuery(''); - setProjects(null); - setDrafts(null); + const value = e.currentTarget.value; + setQuery(value); + if (!value.length) { + setPublishedProjects(null); + setDraftProjects(null); + } + }; + + const handleLoadMore = (is_draft: boolean) => { + const currentState = is_draft ? draftProjects : publishedProjects; + if (currentState && currentState.results.length < currentState.totalDocs) { + fetchProjects({ + page: currentState.page + 1, + is_draft, + query, + deletedDocCount: currentState.deletedDocCount || 0, + }); } }; return ( - <> -

Manage Projects

-
- + + + + } + sx={{ width: '100%' }} + slotProps={{ + htmlInput: { + onChange: handleChange, + onKeyDown: handleSearch, + }, + }} /> - - -
+ - { - // Published Projects - projects === null ? ( - - ) : projects.results.length ? ( - <> - {projects.results.map((project, i) => { - return ( - - ); - })} - - + + + ) : publishedProjects.results.length ? ( + <> + {publishedProjects.results.map((project, i) => ( + - - ) : ( - - ) - } - - { - // Draft Projects - drafts === null ? ( - - ) : drafts.results.length ? ( - <> - {drafts.results.map((project, i) => { - return ( - - ); - })} - - + + + )} + + ) : ( + + )} + + {/* Draft Projects */} + {draftProjects === null ? ( + + + + ) : draftProjects.results.length ? ( + <> + {draftProjects.results.map((project, i) => ( + - - ) : ( - - ) - } + ))} + + {draftProjects.results.length < draftProjects.totalDocs && ( + + + + )} + + ) : ( + + )} - + ); }; diff --git a/client/src/modules/manage-projects/states/index.ts b/client/src/modules/manage-projects/states/index.ts index e69de29bb..4338a6996 100644 --- a/client/src/modules/manage-projects/states/index.ts +++ b/client/src/modules/manage-projects/states/index.ts @@ -0,0 +1,16 @@ +import { atom } from 'jotai'; +import { userProjectsResponse } from '../../../infra/rest/apis/project/typing'; + +export interface ManageProjectsPaginationState { + results: userProjectsResponse[]; + page: number; + totalDocs: number; + deletedDocCount?: number; +} + +export const PublishedProjectsAtom = atom( + null +); +export const DraftProjectsAtom = atom( + null +); diff --git a/client/src/modules/notification/components/notificationCard.tsx b/client/src/modules/notification/components/notificationCard.tsx index ac06b31ef..959e929fa 100644 --- a/client/src/modules/notification/components/notificationCard.tsx +++ b/client/src/modules/notification/components/notificationCard.tsx @@ -1,15 +1,13 @@ import { Link } from 'react-router-dom'; import { useState } from 'react'; -import { useAtom } from 'jotai'; -import { motion } from 'framer-motion'; +import { useAtomValue } from 'jotai'; import { getDay } from '../../../shared/utils/date'; import NotificationCommentField from './notificationCommentField'; -import { UserAtom } from '../../../shared/states/user'; -import { - NotificationData, - NotificationState, -} from '../../../infra/rest/typings'; -import axios from 'axios'; +import { UserAtom } from '../../../infra/states/user'; +import { GetNotificationsResponse } from '../../../infra/rest/apis/notification/typing'; +import { NotificationPaginationState } from '../states'; +import { deleteComment } from '../../../infra/rest/apis/comment'; +import { useNotifications as useNotificationHook } from '../../../shared/hooks/use-notification'; import { ListItem, ListItemAvatar, @@ -34,11 +32,11 @@ import { } from '@mui/icons-material'; interface NotificationCardProps { - data: NotificationData; + data: GetNotificationsResponse; index: number; notificationState: { - notifications: NotificationState; - setNotifications: (state: NotificationState) => void; + notifications: NotificationPaginationState; + setNotifications: (state: NotificationPaginationState) => void; }; } @@ -52,71 +50,44 @@ const NotificationCard = ({ const { seen, type, - reply, createdAt, - comment, - replied_on_comment, - user, - user: { personal_info: { fullname, username, profile_img } = {} } = {}, - project: { _id = '', project_id = '', title = '' } = {}, + comment_id, + replied_on_comment_id, + personal_info: { fullname, username, profile_img } = {}, + project_id: { _id: project_id = '', title = '' } = {}, _id: notification_id, } = data; - const [userAuth] = useAtom(UserAtom); - - const { - username: author_username, - profile_img: author_profile_img, - access_token, - } = userAuth; + const userAuth = useAtomValue(UserAtom); + const { addNotification } = useNotificationHook(); - const { - notifications, - notifications: { results, totalDocs }, - setNotifications, - } = notificationState; + const { notifications, setNotifications } = notificationState; const handleReplyClick = () => { setIsReplying(preVal => !preVal); }; - const handleDelete = ( - comment_id: string, - type: string, - target: EventTarget | null - ) => { - if (!(target instanceof HTMLElement)) return; - - target.setAttribute('disabled', 'true'); - - axios - .post( - import.meta.env.VITE_SERVER_DOMAIN + '/api/notification/delete-comment', - { _id: comment_id }, - { - headers: { - Authorization: `Bearer ${access_token}`, - }, - } - ) - .then(() => { - if (type === 'comment') { - results.splice(index, 1); - } else { - delete results[index].reply; - } - - target.removeAttribute('disabled'); - setNotifications({ - ...notifications, - results, - totalDocs: totalDocs - 1, - deleteDocCount: (notifications.deleteDocCount || 0) + 1, - }); - }) - .catch((err: unknown) => { - console.log(err); + const handleDelete = async (commentId: string) => { + try { + await deleteComment(commentId); + const newResults = notifications.results.filter((_, i) => i !== index); + setNotifications({ + ...notifications, + results: newResults, + totalDocs: notifications.totalDocs - 1, + deleteDocCount: (notifications.deleteDocCount || 0) + 1, + }); + addNotification({ + message: 'Comment deleted successfully', + type: 'success', + }); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + addNotification({ + message: err.response?.data?.error || 'Failed to delete comment', + type: 'error', }); + } }; const getNotificationColor = () => { @@ -133,323 +104,229 @@ const NotificationCard = ({ }; return ( - - - {/* Unread indicator */} - {!seen && ( - - )} + {/* Unread indicator */} + {!seen && ( + + )} - - - - - - {type === 'like' ? ( - - ) : type === 'comment' ? ( - - ) : type === 'reply' ? ( - - ) : ( - - )} - + + + + + + {type === 'like' ? ( + + ) : type === 'comment' ? ( + + ) : type === 'reply' ? ( + + ) : ( + + )} - + + - + - - + + - {fullname} - - - - @{username} - - - - - } - secondary={ - - - - {type === 'like' - ? 'liked your project' - : type === 'comment' - ? 'commented on' - : 'replied to your comment'} + @{username} + + + + } + secondary={ + + + + {type === 'like' + ? 'liked your project' + : type === 'comment' + ? 'commented on' + : 'replied to your comment'} + - {type === 'reply' ? ( - + + "{replied_on_comment_id?.comment || 'No comment'}" + + + ) : ( + + - - "{replied_on_comment?.comment || 'No comment'}" - - - ) : ( - - - - )} - - } - /> - - - {type !== 'like' && comment?.comment && ( - - - - "{comment.comment}" - - - - )} - - - - - - - - - {getDay(createdAt)} - + /> + + )} + } + /> + - {type !== 'like' && ( - - {!reply && ( - - )} - - - handleDelete(comment?._id || '', 'comment', e.target) - } - color="error" - > - - - - )} - + {type !== 'like' && comment_id?.comment && ( + + + + "{comment_id.comment}" + + + )} - - - - - + - {reply && ( - - - - - - - - - @{author_username} - - - - replied to - - - - @{username} - - - - - + + + + + + {getDay(createdAt)} + + - - - "{reply.comment}" - + {type !== 'like' && comment_id && ( + + - handleDelete(reply._id, 'reply', e.target)} - color="error" - > - - - + handleDelete(comment_id._id)} + color="error" + > + + - - )} - - + )} + + + + + + + + + ); }; diff --git a/client/src/modules/notification/components/notificationCommentField.tsx b/client/src/modules/notification/components/notificationCommentField.tsx index 517458d6f..a917df0c1 100644 --- a/client/src/modules/notification/components/notificationCommentField.tsx +++ b/client/src/modules/notification/components/notificationCommentField.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import axios from 'axios'; -import { useAtom } from 'jotai'; -import { UserAtom } from '../../../shared/states/user'; -import { NotificationState } from '../../../infra/rest/typings'; +import { useAtomValue } from 'jotai'; +import { UserAtom } from '../../../infra/states/user'; +import { useAuth } from '../../../shared/hooks/use-auth'; import { TextField, Button, Box, Typography } from '@mui/material'; import { Reply } from '@mui/icons-material'; @@ -11,34 +11,23 @@ interface NotificationCommentFieldProps { project_author: { _id: string; }; - index?: number; replyingTo?: string; setReplying: (value: boolean) => void; notification_id: string; - notificationData: { - notifications: NotificationState; - setNotifications: (state: NotificationState) => void; - }; } const NotificationCommentField = ({ _id, project_author, - index, replyingTo, setReplying, notification_id, - notificationData, }: NotificationCommentFieldProps) => { const [comment, setComment] = useState(''); - const [user] = useAtom(UserAtom); + const user = useAtomValue(UserAtom); + const { isAuthenticated } = useAuth(); const { _id: user_id } = project_author; - const { - notifications, - notifications: { results }, - setNotifications, - } = notificationData; const handleComment = () => { if (!comment.length) { @@ -46,7 +35,7 @@ const NotificationCommentField = ({ return; } - if (!user.access_token) { + if (!isAuthenticated() || !user) { console.error('User not authenticated'); return; } @@ -62,18 +51,15 @@ const NotificationCommentField = ({ notification_id, }, { - headers: { - Authorization: `Bearer ${user.access_token}`, - }, + withCredentials: true, } ) - .then(({ data }: { data: { _id: string } }) => { + .then(() => { setReplying(false); + setComment(''); - if (typeof index === 'number' && results[index]) { - results[index].reply = { comment, _id: data._id }; - setNotifications({ ...notifications, results }); - } + // Refresh notifications to get updated data + // The reply will be handled by the server and returned in the next fetch }) .catch((err: unknown) => { console.log(err); diff --git a/client/src/modules/notification/hooks/index.ts b/client/src/modules/notification/hooks/index.ts new file mode 100644 index 000000000..cea6578c8 --- /dev/null +++ b/client/src/modules/notification/hooks/index.ts @@ -0,0 +1,61 @@ +import { useCallback } from 'react'; +import { useSetAtom, useAtomValue } from 'jotai'; +import { + getNotifications, + allNotificationCounts, +} from '../../../infra/rest/apis/notification'; +import { NotificationsAtom, NotificationPaginationState } from '../states'; +import { useAuth } from '../../../shared/hooks/use-auth'; +import { NOTIFICATION_FILTER_TYPE } from '../../../infra/rest/typings'; + +const useNotifications = () => { + const setNotifications = useSetAtom(NotificationsAtom); + const notifications = useAtomValue(NotificationsAtom); + const { isAuthenticated } = useAuth(); + + const fetchNotifications = useCallback( + async (params: { + page: number; + filter: NOTIFICATION_FILTER_TYPE; + deletedDocCount?: number; + }) => { + if (!isAuthenticated()) return; + + const { page, filter, deletedDocCount = 0 } = params; + + try { + const [notificationsResponse, countResponse] = await Promise.all([ + getNotifications({ page, filter, deletedDocCount }), + allNotificationCounts({ filter }), + ]); + + if (notificationsResponse.data && countResponse.data) { + const currentState = notifications; + const existingResults = currentState?.results || []; + + const formattedData: NotificationPaginationState = { + results: + page === 1 + ? notificationsResponse.data + : [...existingResults, ...notificationsResponse.data], + page, + totalDocs: countResponse.data.totalDocs || 0, + deleteDocCount: deletedDocCount, + }; + + setNotifications(formattedData); + } + } catch (error) { + console.error('Error fetching notifications:', error); + } + }, + [isAuthenticated, setNotifications, notifications] + ); + + return { + fetchNotifications, + notifications, + }; +}; + +export default useNotifications; diff --git a/client/src/modules/notification/index.tsx b/client/src/modules/notification/index.tsx index 6e20f86bb..15bfc057e 100644 --- a/client/src/modules/notification/index.tsx +++ b/client/src/modules/notification/index.tsx @@ -1,13 +1,10 @@ -import { useEffect, useState, useCallback } from 'react'; -import axios from 'axios'; -import { filterPaginationData } from '../../shared/requests/filter-pagination-data'; -import AnimationWrapper from '../../shared/components/atoms/page-animation'; -import LoadMoreDataBtn from '../../shared/components/molecules/load-more-data'; +import { useEffect, useState } from 'react'; import NotificationCard from './components/notificationCard'; import { notificationFilters } from './constants'; -import { useAtom } from 'jotai'; -import { UserAtom } from '../../shared/states/user'; -import { NotificationData } from '../../infra/rest/typings'; +import { useSetAtom } from 'jotai'; +import { NotificationsAtom } from './states'; +import useNotifications from './hooks'; +import { useAuth } from '../../shared/hooks/use-auth'; import { Box, Typography, @@ -23,91 +20,45 @@ import { Comment, Reply, } from '@mui/icons-material'; - -interface PaginationState { - results: T[]; - page: number; - totalDocs: number; -} - -type NotificationPaginationState = PaginationState & { - deleteDocCount?: number; -}; +import { NOTIFICATION_FILTER_TYPE } from '../../infra/rest/typings'; const Notifications = () => { - const [user, setUser] = useAtom(UserAtom); - - const [filter, setFilter] = useState('all'); - const [notifications, setNotifications] = - useState(null); - - const fetchNotifications = useCallback( - (params: Record) => { - const { page, deletedDocCount = 0 } = params; - - if (!user.access_token || typeof page !== 'number') return; - - axios - .post( - import.meta.env.VITE_SERVER_DOMAIN + '/api/notification/get', - { page, filter, deletedDocCount }, - { - headers: { - Authorization: `Bearer ${user.access_token}`, - }, - } - ) - .then( - async ({ - data: { notifications: data }, - }: { - data: { notifications: NotificationData[] }; - }) => { - if (user.new_notification_available) { - setUser(prev => ({ ...prev, new_notification_available: false })); - } - - const formattedData = await filterPaginationData({ - state: notifications, - data, - page, - countRoute: '/api/notification/all-count', - data_to_send: { filter }, - }); - - setNotifications(formattedData as NotificationPaginationState); - } - ) - .catch((err: unknown) => { - console.log(err); - }); - }, - [ - user.access_token, - filter, - notifications, - user.new_notification_available, - setUser, - ] + const setNotifications = useSetAtom(NotificationsAtom); + const [filter, setFilter] = useState( + NOTIFICATION_FILTER_TYPE.ALL ); + const { fetchNotifications, notifications } = useNotifications(); + const { isAuthenticated } = useAuth(); useEffect(() => { - if (user.access_token) { - fetchNotifications({ page: 1 }); + if (isAuthenticated()) { + setNotifications(null); + fetchNotifications({ page: 1, filter, deletedDocCount: 0 }); } - }, [user.access_token, filter, fetchNotifications]); + }, [isAuthenticated, filter, fetchNotifications, setNotifications]); + + const handleFilter = (filterName: string) => { + setFilter(filterName as NOTIFICATION_FILTER_TYPE); + }; - const handleFilter = (e: React.MouseEvent) => { - const btn = e.target as HTMLButtonElement; - setFilter(btn.innerHTML.toLowerCase()); - setNotifications(null); + const handleLoadMore = () => { + if ( + notifications && + notifications.results.length < notifications.totalDocs + ) { + fetchNotifications({ + page: notifications.page + 1, + filter, + deletedDocCount: notifications.deleteDocCount || 0, + }); + } }; return ( - + @@ -140,7 +91,7 @@ const Notifications = () => { key={i} variant={isActive ? 'contained' : 'outlined'} color={isActive ? 'primary' : 'inherit'} - onClick={handleFilter} + onClick={() => handleFilter(filterName)} startIcon={ filterName === 'all' ? ( @@ -187,24 +138,17 @@ const Notifications = () => { <> {notifications.results.length ? ( - {notifications.results.map( - (notification: NotificationData, i: number) => { - return ( - - setNotifications(newState), - }} - /> - - ); - } - )} + {notifications.results.map((notification, i) => ( + + ))} ) : ( { No notifications yet - {filter === 'all' + {filter === NOTIFICATION_FILTER_TYPE.ALL ? "You're all caught up! Check back later for new notifications." : `No ${filter} notifications found.`} )} - {notifications.results.length > 0 && ( - - - - )} + {notifications.results.length > 0 && + notifications.results.length < notifications.totalDocs && ( + + + + )} )} diff --git a/client/src/modules/notification/states/index.ts b/client/src/modules/notification/states/index.ts new file mode 100644 index 000000000..cdd9986e4 --- /dev/null +++ b/client/src/modules/notification/states/index.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai'; +import { GetNotificationsResponse } from '../../../infra/rest/apis/notification/typing'; + +export interface NotificationPaginationState { + results: GetNotificationsResponse[]; + page: number; + totalDocs: number; + deleteDocCount?: number; +} + +export const NotificationsAtom = atom(null); diff --git a/client/src/shared/components/organisms/navbar/components/render-menu.tsx b/client/src/shared/components/organisms/navbar/components/render-menu.tsx index 6d07e29c5..648e8f69c 100644 --- a/client/src/shared/components/organisms/navbar/components/render-menu.tsx +++ b/client/src/shared/components/organisms/navbar/components/render-menu.tsx @@ -1,6 +1,16 @@ import { FC } from 'react'; -import { Menu, MenuItem } from '@mui/material'; +import { Menu, MenuItem, Divider } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { UserAtom } from '../../../../../infra/states/user'; +import { useAuth } from '../../../../../shared/hooks/use-auth'; +import { logout as logoutApi } from '../../../../../infra/rest/apis/auth'; import { menuId } from '../constants'; +import PersonIcon from '@mui/icons-material/Person'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import SettingsIcon from '@mui/icons-material/Settings'; +import LogoutIcon from '@mui/icons-material/Logout'; +import A2ZTypography from '../../../atoms/typography'; interface RenderMenuProps { anchorEl: HTMLElement | null; @@ -13,6 +23,39 @@ const RenderMenu: FC = ({ isMenuOpen, handleMenuClose, }) => { + const navigate = useNavigate(); + const user = useAtomValue(UserAtom); + const { logout } = useAuth(); + + const handleProfileClick = () => { + if (user?.personal_info?.username) { + navigate(`/user/${user.personal_info.username}`); + } + handleMenuClose(); + }; + + const handleDashboardClick = () => { + navigate('/dashboard/projects'); + handleMenuClose(); + }; + + const handleSettingsClick = () => { + navigate('/settings/edit-profile'); + handleMenuClose(); + }; + + const handleLogout = async () => { + try { + await logoutApi(); + } catch (error) { + console.error('Logout error:', error); + } finally { + logout(); + navigate('/'); + handleMenuClose(); + } + }; + return ( = ({ open={isMenuOpen} onClose={handleMenuClose} > - Profile - My account + + + + + + + + + + + + + + + + + ); }; From bb713dadd1c6be062b2d2a1cb1ec67cd5d81243e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 09:05:33 +0000 Subject: [PATCH 2/5] Initial plan From 74577b399e3b9a7a6e64f5228381669d66ec6d05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 09:11:55 +0000 Subject: [PATCH 3/5] Address all PR review comments - fix deps, props, controlled components Co-authored-by: Avdhesh-Varshney <114330097+Avdhesh-Varshney@users.noreply.github.com> --- client/src/modules/edit-profile/index.tsx | 103 +++++++++++------- .../modules/manage-projects/hooks/index.ts | 55 ++++++---- client/src/modules/manage-projects/index.tsx | 6 +- .../components/notificationCard.tsx | 5 +- .../components/notificationCommentField.tsx | 15 +-- .../src/modules/notification/hooks/index.ts | 31 +++--- client/src/modules/notification/index.tsx | 3 +- 7 files changed, 124 insertions(+), 94 deletions(-) diff --git a/client/src/modules/edit-profile/index.tsx b/client/src/modules/edit-profile/index.tsx index 76a9e7fdc..f880a0651 100644 --- a/client/src/modules/edit-profile/index.tsx +++ b/client/src/modules/edit-profile/index.tsx @@ -39,21 +39,53 @@ const EditProfile = () => { const [charactersLeft, setCharactersLeft] = useState(bioLimit); const [updatedProfileImg, setUpdatedProfileImg] = useState(null); const [previewImg, setPreviewImg] = useState(null); + + // Form state + const [formData, setFormData] = useState({ + username: '', + bio: '', + youtube: '', + facebook: '', + twitter: '', + github: '', + instagram: '', + website: '', + }); useEffect(() => { if (isAuthenticated()) { fetchProfile().finally(() => setLoading(false)); } - }, [isAuthenticated, fetchProfile]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { - if (profile?.personal_info?.bio) { - setCharactersLeft(bioLimit - profile.personal_info.bio.length); + if (profile) { + setFormData({ + username: profile.personal_info.username || '', + bio: profile.personal_info.bio || '', + youtube: profile.social_links.youtube || '', + facebook: profile.social_links.facebook || '', + twitter: profile.social_links.x || '', + github: profile.social_links.github || '', + instagram: profile.social_links.instagram || '', + website: profile.social_links.website || '', + }); + if (profile.personal_info?.bio) { + setCharactersLeft(bioLimit - profile.personal_info.bio.length); + } } }, [profile]); const handleCharacterChange = (e: React.ChangeEvent) => { - setCharactersLeft(bioLimit - e.currentTarget.value.length); + const value = e.currentTarget.value; + setFormData(prev => ({ ...prev, bio: value })); + setCharactersLeft(bioLimit - value.length); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget; + setFormData(prev => ({ ...prev, [name]: value })); }; const handleImagePreview = (e: React.ChangeEvent) => { @@ -93,33 +125,16 @@ const EditProfile = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!editProfileForm.current) return; - const form = new FormData(editProfileForm.current); - const formData: { [key: string]: FormDataEntryValue } = {}; + const { username, bio, youtube, facebook, twitter, github, instagram, website } = formData; - for (const [key, value] of form.entries()) { - formData[key] = value; - } - - const { - username, - bio, - youtube, - facebook, - twitter, - github, - instagram, - website, - } = formData; - - if (typeof username !== 'string' || username.length < 3) { + if (username.length < 3) { return addNotification({ message: 'Username should be atleast 3 characters long', type: 'error', }); } - if (typeof bio === 'string' && bio.length > bioLimit) { + if (bio.length > bioLimit) { return addNotification({ message: `Bio should be less than ${bioLimit} characters`, type: 'error', @@ -131,15 +146,15 @@ const EditProfile = () => { try { await updateUserProfile({ username, - bio: (bio as string) || '', + bio: bio || '', social_links: { - youtube: (youtube as string) || '', - facebook: (facebook as string) || '', - x: (twitter as string) || '', - github: (github as string) || '', - instagram: (instagram as string) || '', - linkedin: '', - website: (website as string) || '', + youtube: youtube || '', + facebook: facebook || '', + x: twitter || '', + github: github || '', + instagram: instagram || '', + linkedin: profile?.social_links.linkedin || '', + website: website || '', }, }); addNotification({ @@ -278,9 +293,14 @@ const EditProfile = () => { id="edit-profile-username" type="text" name="username" - defaultValue={username} + value={formData.username} placeholder="Username" icon={} + slotProps={{ + htmlInput: { + onChange: handleInputChange, + }, + }} /> { name="bio" multiline rows={4} - defaultValue={bio} + value={formData.bio} placeholder="Bio" fullWidth inputProps={{ maxLength: bioLimit }} @@ -328,24 +348,25 @@ const EditProfile = () => { gap: 2, }} > - {( - Object.keys(social_links) as Array< - keyof typeof social_links - > - ).map(key => { - const link = social_links[key] || ''; + {['youtube', 'facebook', 'twitter', 'github', 'instagram', 'website'].map(key => { + const fieldName = key as keyof typeof formData; return ( } sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)' }, }} + slotProps={{ + htmlInput: { + onChange: handleInputChange, + }, + }} /> ); })} diff --git a/client/src/modules/manage-projects/hooks/index.ts b/client/src/modules/manage-projects/hooks/index.ts index df1607e25..c5dc2671e 100644 --- a/client/src/modules/manage-projects/hooks/index.ts +++ b/client/src/modules/manage-projects/hooks/index.ts @@ -36,36 +36,49 @@ const useManageProjects = () => { ]); if (projectsResponse.data && countResponse.data) { - const currentState = is_draft ? draftProjects : publishedProjects; - const existingResults = currentState?.results || []; - - const formattedData: ManageProjectsPaginationState = { - results: - page === 1 - ? projectsResponse.data - : [...existingResults, ...projectsResponse.data], - page, - totalDocs: countResponse.data.totalDocs || 0, - deletedDocCount, - }; + const totalDocs = countResponse.data.totalDocs || 0; if (is_draft) { - setDraftProjects(formattedData); + setDraftProjects((prevState: ManageProjectsPaginationState | undefined) => { + const previousResults = prevState?.results || []; + + const results = + page === 1 || !prevState + ? projectsResponse.data + : [...previousResults, ...projectsResponse.data]; + + return { + results, + page, + totalDocs, + deletedDocCount, + }; + }); } else { - setPublishedProjects(formattedData); + setPublishedProjects( + (prevState: ManageProjectsPaginationState | undefined) => { + const previousResults = prevState?.results || []; + + const results = + page === 1 || !prevState + ? projectsResponse.data + : [...previousResults, ...projectsResponse.data]; + + return { + results, + page, + totalDocs, + deletedDocCount, + }; + } + ); } } } catch (error) { console.error('Error fetching projects:', error); } }, - [ - isAuthenticated, - setPublishedProjects, - setDraftProjects, - publishedProjects, - draftProjects, - ] + [isAuthenticated, setPublishedProjects, setDraftProjects] ); return { diff --git a/client/src/modules/manage-projects/index.tsx b/client/src/modules/manage-projects/index.tsx index 228c777e5..39ebf739d 100644 --- a/client/src/modules/manage-projects/index.tsx +++ b/client/src/modules/manage-projects/index.tsx @@ -29,10 +29,12 @@ const ManageProjects = () => { if (draftProjects === null) { fetchProjects({ page: 1, is_draft: true, query }); } - }, [publishedProjects, draftProjects, fetchProjects, query]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [publishedProjects, draftProjects, query]); const handleSearch = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && query.length) { + const value = e.currentTarget.value; + if (e.key === 'Enter' && value.length) { setPublishedProjects(null); setDraftProjects(null); } diff --git a/client/src/modules/notification/components/notificationCard.tsx b/client/src/modules/notification/components/notificationCard.tsx index 959e929fa..d0503b110 100644 --- a/client/src/modules/notification/components/notificationCard.tsx +++ b/client/src/modules/notification/components/notificationCard.tsx @@ -112,7 +112,7 @@ const NotificationCard = ({ borderLeftColor: `${getNotificationColor()}.main`, position: 'relative', '&:hover': { - elevation: 4, + boxShadow: theme => theme.shadows[4], transform: 'scale(1.01)', transition: 'all 0.2s ease-in-out', }, @@ -318,8 +318,7 @@ const NotificationCard = ({ void; notification_id: string; } const NotificationCommentField = ({ - _id, - project_author, + project_id, replyingTo, setReplying, notification_id, @@ -27,8 +23,6 @@ const NotificationCommentField = ({ const user = useAtomValue(UserAtom); const { isAuthenticated } = useAuth(); - const { _id: user_id } = project_author; - const handleComment = () => { if (!comment.length) { console.error('Write something to leave a comment...'); @@ -42,11 +36,10 @@ const NotificationCommentField = ({ axios .post( - import.meta.env.VITE_SERVER_DOMAIN + '/api/notification/comment', + import.meta.env.VITE_SERVER_DOMAIN + '/api/comment', { - _id, + project_id, comment, - project_author: user_id, replying_to: replyingTo, notification_id, }, diff --git a/client/src/modules/notification/hooks/index.ts b/client/src/modules/notification/hooks/index.ts index cea6578c8..f177213d2 100644 --- a/client/src/modules/notification/hooks/index.ts +++ b/client/src/modules/notification/hooks/index.ts @@ -30,26 +30,27 @@ const useNotifications = () => { ]); if (notificationsResponse.data && countResponse.data) { - const currentState = notifications; - const existingResults = currentState?.results || []; - - const formattedData: NotificationPaginationState = { - results: - page === 1 - ? notificationsResponse.data - : [...existingResults, ...notificationsResponse.data], - page, - totalDocs: countResponse.data.totalDocs || 0, - deleteDocCount: deletedDocCount, - }; - - setNotifications(formattedData); + setNotifications((currentState) => { + const existingResults = currentState?.results || []; + + const formattedData: NotificationPaginationState = { + results: + page === 1 + ? notificationsResponse.data + : [...existingResults, ...notificationsResponse.data], + page, + totalDocs: countResponse.data.totalDocs || 0, + deleteDocCount: deletedDocCount, + }; + + return formattedData; + }); } } catch (error) { console.error('Error fetching notifications:', error); } }, - [isAuthenticated, setNotifications, notifications] + [isAuthenticated, setNotifications] ); return { diff --git a/client/src/modules/notification/index.tsx b/client/src/modules/notification/index.tsx index 15bfc057e..26a3f266f 100644 --- a/client/src/modules/notification/index.tsx +++ b/client/src/modules/notification/index.tsx @@ -35,7 +35,8 @@ const Notifications = () => { setNotifications(null); fetchNotifications({ page: 1, filter, deletedDocCount: 0 }); } - }, [isAuthenticated, filter, fetchNotifications, setNotifications]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter]); const handleFilter = (filterName: string) => { setFilter(filterName as NOTIFICATION_FILTER_TYPE); From ae5a7c9a2e3da3aa3fab53b17c4f4bcdac845a48 Mon Sep 17 00:00:00 2001 From: Avdhesh-Varshney Date: Sun, 4 Jan 2026 14:47:27 +0530 Subject: [PATCH 4/5] formatted --- client/src/modules/edit-profile/index.tsx | 25 +++++++++++++---- .../modules/manage-projects/hooks/index.ts | 28 ++++++++++--------- .../src/modules/notification/hooks/index.ts | 2 +- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/client/src/modules/edit-profile/index.tsx b/client/src/modules/edit-profile/index.tsx index f880a0651..30644db53 100644 --- a/client/src/modules/edit-profile/index.tsx +++ b/client/src/modules/edit-profile/index.tsx @@ -39,7 +39,7 @@ const EditProfile = () => { const [charactersLeft, setCharactersLeft] = useState(bioLimit); const [updatedProfileImg, setUpdatedProfileImg] = useState(null); const [previewImg, setPreviewImg] = useState(null); - + // Form state const [formData, setFormData] = useState({ username: '', @@ -125,7 +125,16 @@ const EditProfile = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const { username, bio, youtube, facebook, twitter, github, instagram, website } = formData; + const { + username, + bio, + youtube, + facebook, + twitter, + github, + instagram, + website, + } = formData; if (username.length < 3) { return addNotification({ @@ -181,8 +190,7 @@ const EditProfile = () => { } const { - personal_info: { fullname, username, profile_img, bio }, - social_links, + personal_info: { fullname, profile_img }, } = profile; const socialIcons: Record = { @@ -348,7 +356,14 @@ const EditProfile = () => { gap: 2, }} > - {['youtube', 'facebook', 'twitter', 'github', 'instagram', 'website'].map(key => { + {[ + 'youtube', + 'facebook', + 'twitter', + 'github', + 'instagram', + 'website', + ].map(key => { const fieldName = key as keyof typeof formData; return ( { const totalDocs = countResponse.data.totalDocs || 0; if (is_draft) { - setDraftProjects((prevState: ManageProjectsPaginationState | undefined) => { - const previousResults = prevState?.results || []; + setDraftProjects( + (prevState: ManageProjectsPaginationState | undefined) => { + const previousResults = prevState?.results || []; - const results = - page === 1 || !prevState - ? projectsResponse.data - : [...previousResults, ...projectsResponse.data]; + const results = + page === 1 || !prevState + ? projectsResponse.data + : [...previousResults, ...projectsResponse.data]; - return { - results, - page, - totalDocs, - deletedDocCount, - }; - }); + return { + results, + page, + totalDocs, + deletedDocCount, + }; + } + ); } else { setPublishedProjects( (prevState: ManageProjectsPaginationState | undefined) => { diff --git a/client/src/modules/notification/hooks/index.ts b/client/src/modules/notification/hooks/index.ts index f177213d2..4c5cd9838 100644 --- a/client/src/modules/notification/hooks/index.ts +++ b/client/src/modules/notification/hooks/index.ts @@ -30,7 +30,7 @@ const useNotifications = () => { ]); if (notificationsResponse.data && countResponse.data) { - setNotifications((currentState) => { + setNotifications(currentState => { const existingResults = currentState?.results || []; const formattedData: NotificationPaginationState = { From 5c495423468c17d153adced252bca5a43a86f723 Mon Sep 17 00:00:00 2001 From: Avdhesh-Varshney Date: Sun, 4 Jan 2026 14:50:54 +0530 Subject: [PATCH 5/5] fix eslint errors --- client/src/modules/manage-projects/hooks/index.ts | 13 +++++++------ client/src/modules/notification/hooks/index.ts | 7 +++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/modules/manage-projects/hooks/index.ts b/client/src/modules/manage-projects/hooks/index.ts index 587b45a64..1f7f35e5e 100644 --- a/client/src/modules/manage-projects/hooks/index.ts +++ b/client/src/modules/manage-projects/hooks/index.ts @@ -37,16 +37,17 @@ const useManageProjects = () => { if (projectsResponse.data && countResponse.data) { const totalDocs = countResponse.data.totalDocs || 0; + const newResults = projectsResponse.data || []; if (is_draft) { setDraftProjects( - (prevState: ManageProjectsPaginationState | undefined) => { + (prevState: ManageProjectsPaginationState | null) => { const previousResults = prevState?.results || []; const results = page === 1 || !prevState - ? projectsResponse.data - : [...previousResults, ...projectsResponse.data]; + ? newResults + : [...previousResults, ...newResults]; return { results, @@ -58,13 +59,13 @@ const useManageProjects = () => { ); } else { setPublishedProjects( - (prevState: ManageProjectsPaginationState | undefined) => { + (prevState: ManageProjectsPaginationState | null) => { const previousResults = prevState?.results || []; const results = page === 1 || !prevState - ? projectsResponse.data - : [...previousResults, ...projectsResponse.data]; + ? newResults + : [...previousResults, ...newResults]; return { results, diff --git a/client/src/modules/notification/hooks/index.ts b/client/src/modules/notification/hooks/index.ts index 4c5cd9838..2772fbe50 100644 --- a/client/src/modules/notification/hooks/index.ts +++ b/client/src/modules/notification/hooks/index.ts @@ -32,14 +32,13 @@ const useNotifications = () => { if (notificationsResponse.data && countResponse.data) { setNotifications(currentState => { const existingResults = currentState?.results || []; + const newResults = notificationsResponse.data || []; const formattedData: NotificationPaginationState = { results: - page === 1 - ? notificationsResponse.data - : [...existingResults, ...notificationsResponse.data], + page === 1 ? newResults : [...existingResults, ...newResults], page, - totalDocs: countResponse.data.totalDocs || 0, + totalDocs: countResponse.data?.totalDocs || 0, deleteDocCount: deletedDocCount, };