diff --git a/client/src/App.tsx b/client/src/App.tsx index 2f3bd185d..d1ec5c6ab 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,4 @@ -import { memo, useEffect, useState, useMemo } from 'react'; -import { Provider } from 'jotai'; +import { memo, useEffect, useMemo } from 'react'; import { setupTokenRefresh } from './shared/utils/api-interceptor'; import { AppUnProtectedRoutes } from './modules/app/routes'; import { AppProtectedRoutes } from './modules/app/routes/auth-routes'; @@ -8,7 +7,6 @@ import { useAuth } from './shared/hooks/use-auth'; const App = memo(() => { const { GlobalScrollbar } = useScrollbar(); - const [cacheKey, setCacheKey] = useState(''); const { token } = useAuth(); const isAuth = useMemo(() => !!token, [token]); @@ -16,10 +14,6 @@ const App = memo(() => { setupTokenRefresh(); }, []); - useEffect(() => { - setCacheKey(Date.now().toString()); - }, [isAuth]); - if (!isAuth) { return ( <> @@ -30,10 +24,10 @@ const App = memo(() => { } return ( - + <> - + ); }); diff --git a/client/src/infra/rest/apis/user/index.ts b/client/src/infra/rest/apis/user/index.ts index 3423177e9..3b4904594 100644 --- a/client/src/infra/rest/apis/user/index.ts +++ b/client/src/infra/rest/apis/user/index.ts @@ -1,5 +1,6 @@ import { get, patch } from '../..'; import { ApiResponse } from '../../typings'; +import { USER_DB_STATE } from '../../typings'; import { getUserProfileResponse, searchUserResponse, @@ -18,6 +19,10 @@ export const userProfile = async (username: string) => { ); }; +export const getCurrentUser = async () => { + return get>(`/api/user/me`, true); +}; + export const updateProfileImg = async (imageUrl: string) => { return patch<{ url: string }, ApiResponse<{ profile_img: string }>>( `/api/user/update-profile-img`, diff --git a/client/src/main.tsx b/client/src/main.tsx index c18c1c2fa..dabc09390 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { Provider } from 'jotai'; import App from './App'; import createCache from '@emotion/cache'; import { CacheProvider } from '@emotion/react'; @@ -14,13 +15,15 @@ const cache = createCache({ createRoot(document.getElementById('root')!).render( - - - - - - - - + + + + + + + + + + ); diff --git a/client/src/modules/app/routes/auth-routes/protected-route/index.tsx b/client/src/modules/app/routes/auth-routes/protected-route/index.tsx new file mode 100644 index 000000000..837430fa7 --- /dev/null +++ b/client/src/modules/app/routes/auth-routes/protected-route/index.tsx @@ -0,0 +1,22 @@ +import { ComponentType, LazyExoticComponent, Suspense } from 'react'; +import Loader from '../../../../../shared/components/molecules/loader'; +import { LOADING } from '../../constants'; +import ProtectedPlaceholder from './protected'; + +export const ProtectedRoute = ({ + component: LazyComponent, + hasAccess, +}: { + component: LazyExoticComponent>>; + hasAccess: boolean; +}) => { + if (hasAccess) { + return ( + }> + + + ); + } + + return ; +}; diff --git a/client/src/modules/app/routes/auth-routes/protected-route/protected.tsx b/client/src/modules/app/routes/auth-routes/protected-route/protected.tsx new file mode 100644 index 000000000..d6e111e4f --- /dev/null +++ b/client/src/modules/app/routes/auth-routes/protected-route/protected.tsx @@ -0,0 +1,108 @@ +import { FC } from 'react'; +import { Box, Typography, useTheme } from '@mui/material'; +import { useCustomNavigate } from '../../../../../shared/hooks/use-custom-navigate'; +import A2ZButton from '../../../../../shared/components/atoms/button'; +import { ROUTES_V1 } from '../../constants/routes'; + +interface ProtectedPlaceholderProps { + onClick?: () => void; + heading?: string; + description?: string; + buttonText?: string; + showButton?: boolean; +} + +const ProtectedPlaceholder: FC = ({ + heading = 'Access Denied', + description = 'You do not have access to this content.', + onClick, + buttonText = 'Go to Home', + showButton = true, +}) => { + const theme = useTheme(); + const navigate = useCustomNavigate(); + + return ( + + + + {heading} + + + {description} + + + {showButton && ( + { + if (onClick) { + onClick(); + } else { + navigate({ pathname: ROUTES_V1.HOME.replace('/*', '/') }); + } + }} + > + {buttonText} + + )} + + + ); +}; + +export default ProtectedPlaceholder; diff --git a/client/src/modules/app/routes/auth-routes/v1/index.tsx b/client/src/modules/app/routes/auth-routes/v1/index.tsx index bab73c9a2..0b2bdf2f9 100644 --- a/client/src/modules/app/routes/auth-routes/v1/index.tsx +++ b/client/src/modules/app/routes/auth-routes/v1/index.tsx @@ -20,8 +20,8 @@ export default function getRoutesV1() { } />, }> diff --git a/client/src/modules/app/routes/constants/routes.ts b/client/src/modules/app/routes/constants/routes.ts index 1f68e7f6f..01dc37ead 100644 --- a/client/src/modules/app/routes/constants/routes.ts +++ b/client/src/modules/app/routes/constants/routes.ts @@ -1,6 +1,7 @@ export enum ROUTES_V1 { HOME = '/v1/home', SETTINGS = '/v1/settings', + SETTINGS_PROFILE = '/profile', } export enum ROUTES_PAGE_V1 { diff --git a/client/src/modules/edit-profile/constants/index.ts b/client/src/modules/edit-profile/constants/index.ts deleted file mode 100644 index 2453be8fe..000000000 --- a/client/src/modules/edit-profile/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const bioLimit = 150; diff --git a/client/src/modules/edit-profile/hooks/index.ts b/client/src/modules/edit-profile/hooks/index.ts deleted file mode 100644 index f00b8c62c..000000000 --- a/client/src/modules/edit-profile/hooks/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 30644db53..000000000 --- a/client/src/modules/edit-profile/index.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -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 { addNotification } = useNotifications(); - const { isAuthenticated } = useAuth(); - const { fetchProfile, updateProfileImage, updateUserProfile, profile } = - useEditProfile(); - - const profileImgInputRef = useRef(null); - const editProfileForm = useRef(null); - - 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 [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)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - 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) => { - 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) => { - const img = e.currentTarget.files?.[0]; - if (img) { - setPreviewImg(URL.createObjectURL(img)); - setUpdatedProfileImg(img); - } - }; - - const handleImageUpload = async () => { - if (!updatedProfileImg) return; - - 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 = async (e: React.FormEvent) => { - e.preventDefault(); - - const { - username, - bio, - youtube, - facebook, - twitter, - github, - instagram, - website, - } = formData; - - if (username.length < 3) { - return addNotification({ - message: 'Username should be atleast 3 characters long', - type: 'error', - }); - } - - if (bio.length > bioLimit) { - return addNotification({ - message: `Bio should be less than ${bioLimit} characters`, - type: 'error', - }); - } - - setSaving(true); - - try { - await updateUserProfile({ - username, - bio: bio || '', - social_links: { - youtube: youtube || '', - facebook: facebook || '', - x: twitter || '', - github: github || '', - instagram: instagram || '', - linkedin: profile?.social_links.linkedin || '', - website: website || '', - }, - }); - 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); - } - }; - - if (loading || !profile) { - return ( - - - - ); - } - - const { - personal_info: { fullname, profile_img }, - } = profile; - - const socialIcons: Record = { - youtube: , - facebook: , - twitter: , - github: , - instagram: , - website: , - }; - - return ( - - - - - - {/* Profile Image Section */} - - - - profileImgInputRef.current?.click()} - /> - profileImgInputRef.current?.click()} - > - - - - - - - - - - {/* Form Fields */} - - - - } - sx={{ flex: 1 }} - /> - - - - } - slotProps={{ - htmlInput: { - onChange: handleInputChange, - }, - }} - /> - - Username will be used to search user and will be visible to - all users - - - - - - - {charactersLeft} characters left - - - - - - Add your social handles below - - - {[ - '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, - }, - }} - /> - ); - })} - - - - - - - - - - ); -}; - -export default EditProfile; diff --git a/client/src/modules/edit-profile/states/index.ts b/client/src/modules/edit-profile/states/index.ts deleted file mode 100644 index cf36f31ea..000000000 --- a/client/src/modules/edit-profile/states/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atom } from 'jotai'; -import { getUserProfileResponse } from '../../../infra/rest/apis/user/typing'; - -export const EditProfileAtom = atom(null); diff --git a/client/src/modules/notification/components/notificationCard.tsx b/client/src/modules/notification/components/notificationCard.tsx index d0503b110..ecebd473b 100644 --- a/client/src/modules/notification/components/notificationCard.tsx +++ b/client/src/modules/notification/components/notificationCard.tsx @@ -1,9 +1,7 @@ import { Link } from 'react-router-dom'; import { useState } from 'react'; -import { useAtomValue } from 'jotai'; import { getDay } from '../../../shared/utils/date'; import NotificationCommentField from './notificationCommentField'; -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'; @@ -58,7 +56,6 @@ const NotificationCard = ({ _id: notification_id, } = data; - const userAuth = useAtomValue(UserAtom); const { addNotification } = useNotificationHook(); const { notifications, setNotifications } = notificationState; diff --git a/client/src/modules/notification/index.tsx b/client/src/modules/notification/index.tsx index 7138ca740..2126c8ae6 100644 --- a/client/src/modules/notification/index.tsx +++ b/client/src/modules/notification/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState,useRef,useCallback } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import NotificationCard from './components/notificationCard'; import { notificationFilters } from './constants'; import { useSetAtom } from 'jotai'; @@ -33,7 +33,6 @@ const Notifications = () => { const [isLoadingMore, setIsLoadingMore] = useState(false); const [isFilterLoading, setIsFilterLoading] = useState(false); - // Avoid refetching notifications when the same filter is selected again useEffect(() => { if (!isAuthenticated()) return; @@ -46,19 +45,22 @@ const Notifications = () => { fetchNotifications({ page: 1, filter, - deletedDocCount: 0, + deletedDocCount: 0, }).finally(() => { setIsFilterLoading(false); -}); - - }, [filter, isAuthenticated, fetchNotifications, notifications, setNotifications]); - + }); + }, [ + filter, + isAuthenticated, + fetchNotifications, + notifications, + setNotifications, + ]); const handleFilter = useCallback((filterName: string) => { setFilter(filterName as NOTIFICATION_FILTER_TYPE); }, []); - const handleLoadMore = async () => { if ( !notifications || @@ -75,14 +77,11 @@ const Notifications = () => { filter, deletedDocCount: notifications.deleteDocCount || 0, }); - } - - finally { + } finally { setIsLoadingMore(false); } }; - return ( diff --git a/client/src/modules/profile/index.tsx b/client/src/modules/profile/index.tsx index 23e0a3483..74eccace9 100644 --- a/client/src/modules/profile/index.tsx +++ b/client/src/modules/profile/index.tsx @@ -31,7 +31,14 @@ const Profile = () => { if (projects.length === 0 || profile?.personal_info.username !== username) { fetchUserProfile(); } - }, [username]); + }, [ + username, + user?.personal_info.username, + profile?.personal_info.username, + projects.length, + setProjects, + fetchUserProfile, + ]); if (!profile) { return ; diff --git a/client/src/modules/settings/modules/profile/v1/components/profile-image-upload.tsx b/client/src/modules/settings/modules/profile/v1/components/profile-image-upload.tsx new file mode 100644 index 000000000..278918c86 --- /dev/null +++ b/client/src/modules/settings/modules/profile/v1/components/profile-image-upload.tsx @@ -0,0 +1,126 @@ +import { useRef, useState, useEffect } from 'react'; +import { Box, Avatar, Button, Stack, CircularProgress } from '@mui/material'; +import PhotoCameraIcon from '@mui/icons-material/PhotoCamera'; +import { AVATAR_SIZE, ACCEPTED_IMAGE_TYPES } from '../constants'; +import { ProfileImageUploadProps } from '../typings'; + +const ProfileImageUpload = ({ + currentImage, + fullname, + onImageSelect, + onUpload, + uploading, + disabled = false, +}: ProfileImageUploadProps) => { + const profileImgInputRef = useRef(null); + const [previewImg, setPreviewImg] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + + // Clear preview when upload completes successfully + useEffect(() => { + if (!uploading && selectedFile) { + // Upload completed, clear the preview + setPreviewImg(null); + setSelectedFile(null); + if (profileImgInputRef.current) { + profileImgInputRef.current.value = ''; + } + } + }, [uploading, selectedFile]); + + const handleImagePreview = (e: React.ChangeEvent) => { + const img = e.currentTarget.files?.[0]; + if (img) { + const previewUrl = URL.createObjectURL(img); + setPreviewImg(previewUrl); + setSelectedFile(img); + onImageSelect(img); + } + }; + + const handleUpload = async () => { + if (!selectedFile) return; + try { + await onUpload(); + } catch (error) { + // Error handling is done in parent component + console.error('Error uploading image:', error); + } + }; + + const handleAvatarClick = () => { + if (!disabled) { + profileImgInputRef.current?.click(); + } + }; + + return ( + + + + {!disabled && ( + + + + )} + + + + + + ); +}; + +export default ProfileImageUpload; diff --git a/client/src/modules/settings/modules/profile/v1/components/social-link-input.tsx b/client/src/modules/settings/modules/profile/v1/components/social-link-input.tsx new file mode 100644 index 000000000..22618ac5c --- /dev/null +++ b/client/src/modules/settings/modules/profile/v1/components/social-link-input.tsx @@ -0,0 +1,70 @@ +import { Box } from '@mui/material'; +import A2ZTypography from '../../../../../../shared/components/atoms/typography'; +import InputBox from '../../../../../../shared/components/atoms/input-box'; +import { SOCIAL_LINKS_CONFIG } from '../constants'; +import { SocialLinkInputProps } from '../typings'; +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 socialIcons: Record = { + youtube: , + facebook: , + x: , + github: , + instagram: , + website: , +}; + +const SocialLinkInput = ({ formData, onChange }: SocialLinkInputProps) => { + return ( + + + + {SOCIAL_LINKS_CONFIG.map(({ key, placeholder }) => { + const fieldValue = formData[key as keyof typeof formData] || ''; + return ( + } + sx={{ + flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)' }, + }} + slotProps={{ + htmlInput: { + onChange, + }, + }} + /> + ); + })} + + + ); +}; + +export default SocialLinkInput; diff --git a/client/src/modules/settings/modules/profile/v1/constants/index.ts b/client/src/modules/settings/modules/profile/v1/constants/index.ts new file mode 100644 index 000000000..570d53ff3 --- /dev/null +++ b/client/src/modules/settings/modules/profile/v1/constants/index.ts @@ -0,0 +1,29 @@ +export const BIO_LIMIT = 150; +export const MIN_USERNAME_LENGTH = 3; +export const AVATAR_SIZE = 192; +export const ACCEPTED_IMAGE_TYPES = '.jpeg,.png,.jpg'; + +export const SOCIAL_LINKS_CONFIG = [ + { + key: 'youtube', + label: 'YouTube', + placeholder: 'https://youtube.com/@username', + }, + { + key: 'facebook', + label: 'Facebook', + placeholder: 'https://facebook.com/username', + }, + { key: 'x', label: 'Twitter/X', placeholder: 'https://x.com/username' }, + { + key: 'github', + label: 'GitHub', + placeholder: 'https://github.com/username', + }, + { + key: 'instagram', + label: 'Instagram', + placeholder: 'https://instagram.com/username', + }, + { key: 'website', label: 'Website', placeholder: 'https://yourwebsite.com' }, +] as const; diff --git a/client/src/modules/settings/modules/profile/v1/hooks/index.ts b/client/src/modules/settings/modules/profile/v1/hooks/index.ts new file mode 100644 index 000000000..17ad9f50e --- /dev/null +++ b/client/src/modules/settings/modules/profile/v1/hooks/index.ts @@ -0,0 +1,278 @@ +import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useSetAtom, useAtomValue } from 'jotai'; +import { + updateProfile, + updateProfileImg, +} from '../../../../../../infra/rest/apis/user'; +import { uploadImage } from '../../../../../../infra/rest/apis/media'; +import { UserAtom } from '../../../../../../infra/states/user'; +import { USER_DB_STATE } from '../../../../../../infra/rest/typings'; +import { ProfileFormData, UpdateProfilePayload } from '../typings'; +import { BIO_LIMIT, MIN_USERNAME_LENGTH } from '../constants'; + +interface UseProfileSettingsReturn { + user: USER_DB_STATE | null; + formData: ProfileFormData; + charactersLeft: number; + uploading: boolean; + saving: boolean; + handleInputChange: (e: React.ChangeEvent) => void; + handleCharacterChange: (e: React.ChangeEvent) => void; + handleImageSelect: (file: File) => void; + handleImageUpload: () => Promise; + handleSubmit: (e: React.FormEvent) => Promise; + validateForm: (formData: ProfileFormData) => { + isValid: boolean; + error?: string; + }; + handleSocialLinkChange: (e: React.ChangeEvent) => void; +} + +const useProfileSettings = (): UseProfileSettingsReturn => { + const setUser = useSetAtom(UserAtom); + const user = useAtomValue(UserAtom); + + const [uploading, setUploading] = useState(false); + const [saving, setSaving] = useState(false); + const [selectedImageFile, setSelectedImageFile] = useState(null); + const [charactersLeft, setCharactersLeft] = useState(BIO_LIMIT); + + // Initialize form data from UserAtom + const initialFormData = useMemo(() => { + if (!user) { + return { + username: '', + bio: '', + social_links: { + youtube: '', + facebook: '', + x: '', + github: '', + instagram: '', + linkedin: '', + website: '', + }, + }; + } + + return { + username: user.personal_info.username || '', + bio: user.personal_info.bio || '', + social_links: { + youtube: user.social_links.youtube || '', + facebook: user.social_links.facebook || '', + x: user.social_links.x || '', + github: user.social_links.github || '', + instagram: user.social_links.instagram || '', + linkedin: user.social_links.linkedin || '', + website: user.social_links.website || '', + }, + }; + }, [user]); + + const [formData, setFormData] = useState(initialFormData); + + // Update form data when user changes + useEffect(() => { + setFormData(initialFormData); + if (initialFormData.bio) { + setCharactersLeft(BIO_LIMIT - initialFormData.bio.length); + } else { + setCharactersLeft(BIO_LIMIT); + } + }, [initialFormData]); + + const validateForm = useCallback( + (data: ProfileFormData): { isValid: boolean; error?: string } => { + if (data.username.length < MIN_USERNAME_LENGTH) { + return { + isValid: false, + error: `Username should be at least ${MIN_USERNAME_LENGTH} characters long`, + }; + } + + if (data.bio.length > BIO_LIMIT) { + return { + isValid: false, + error: `Bio should be less than ${BIO_LIMIT} characters`, + }; + } + + return { isValid: true }; + }, + [] + ); + + const handleSocialLinkChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget; + setFormData(prev => ({ + ...prev, + social_links: { + ...prev.social_links, + [name]: value, + }, + })); + }, + [] + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget; + // Handle social links fields + if ( + [ + 'youtube', + 'facebook', + 'x', + 'github', + 'instagram', + 'linkedin', + 'website', + ].includes(name) + ) { + setFormData(prev => ({ + ...prev, + social_links: { + ...prev.social_links, + [name]: value, + }, + })); + } else { + // Handle other fields like username + setFormData(prev => ({ ...prev, [name]: value })); + } + }, + [] + ); + + const handleCharacterChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.currentTarget.value; + setFormData(prev => ({ ...prev, bio: value })); + setCharactersLeft(BIO_LIMIT - value.length); + }, + [] + ); + + const handleImageSelect = useCallback((file: File) => { + setSelectedImageFile(file); + }, []); + + const updateProfileImage = useCallback( + async (imageFile: File): Promise => { + if (!user) return null; + + try { + 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_DB_STATE = { + ...user, + personal_info: { + ...user.personal_info, + profile_img: response.data.profile_img, + }, + }; + setUser(updatedUser); + return response.data.profile_img; + } + } + return null; + } catch (error) { + console.error('Error updating profile image:', error); + throw error; + } + }, + [user, setUser] + ); + + const handleImageUpload = useCallback(async () => { + if (!selectedImageFile) return; + + setUploading(true); + try { + await updateProfileImage(selectedImageFile); + setSelectedImageFile(null); + } finally { + setUploading(false); + } + }, [selectedImageFile, updateProfileImage]); + + const updateUserProfile = useCallback( + async (profileData: UpdateProfilePayload) => { + if (!user) { + throw new Error('User not found'); + } + + try { + const response = await updateProfile(profileData); + if (response.data && user) { + const updatedUser: USER_DB_STATE = { + ...user, + personal_info: { + ...user.personal_info, + username: response.data.username, + bio: profileData.bio, + }, + social_links: { + ...user.social_links, + ...profileData.social_links, + }, + }; + setUser(updatedUser); + } + return response; + } catch (error) { + console.error('Error updating profile:', error); + throw error; + } + }, + [user, setUser] + ); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + + const validation = validateForm(formData); + if (!validation.isValid) { + throw new Error(validation.error); + } + + setSaving(true); + + try { + await updateUserProfile({ + username: formData.username, + bio: formData.bio || '', + social_links: formData.social_links, + }); + } finally { + setSaving(false); + } + }, + [formData, validateForm, updateUserProfile] + ); + + return { + user, + formData, + charactersLeft, + uploading, + saving, + handleInputChange, + handleCharacterChange, + handleImageSelect, + handleImageUpload, + handleSubmit, + validateForm, + handleSocialLinkChange, + }; +}; + +export default useProfileSettings; diff --git a/client/src/modules/settings/modules/profile/v1/index.tsx b/client/src/modules/settings/modules/profile/v1/index.tsx new file mode 100644 index 000000000..b565e125c --- /dev/null +++ b/client/src/modules/settings/modules/profile/v1/index.tsx @@ -0,0 +1,217 @@ +import { useNotifications } from '../../../../../shared/hooks/use-notification'; +import useProfileSettings from './hooks'; +import { Box, Button, TextField, Stack, CircularProgress } from '@mui/material'; +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 ProfileImageUpload from './components/profile-image-upload'; +import SocialLinkInput from './components/social-link-input'; +import { AVATAR_SIZE, BIO_LIMIT } from './constants'; + +const ProfileSettings = () => { + const { addNotification } = useNotifications(); + const { + user, + formData, + charactersLeft, + uploading, + saving, + handleInputChange, + handleCharacterChange, + handleImageSelect, + handleImageUpload, + handleSubmit, + handleSocialLinkChange, + } = useProfileSettings(); + + const handleImageUploadWithNotification = async () => { + try { + await handleImageUpload(); + addNotification({ + message: 'Profile Image Updated', + type: 'success', + }); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + addNotification({ + message: err.response?.data?.error || 'Failed to update profile image', + type: 'error', + }); + } + }; + + const handleFormSubmit = async (e: React.FormEvent) => { + try { + await handleSubmit(e); + addNotification({ + message: 'Profile Updated', + type: 'success', + }); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + const validationError = + error instanceof Error ? error.message : undefined; + + addNotification({ + message: + err.response?.data?.error || + validationError || + 'Failed to update profile', + type: 'error', + }); + } + }; + + if (!user) { + return ( + + + + ); + } + + const { + personal_info: { fullname, profile_img }, + } = user; + + return ( + + + + + + + + + + + } + sx={{ flex: 1 }} + /> + + + + } + slotProps={{ + htmlInput: { + onChange: handleInputChange, + }, + }} + /> + + + + + + + + + + + + + + + + + ); +}; + +export default ProfileSettings; diff --git a/client/src/modules/settings/modules/profile/v1/typings/index.ts b/client/src/modules/settings/modules/profile/v1/typings/index.ts new file mode 100644 index 000000000..72c1f402c --- /dev/null +++ b/client/src/modules/settings/modules/profile/v1/typings/index.ts @@ -0,0 +1,33 @@ +import { updateProfilePayload } from '../../../../../../infra/rest/apis/user/typing'; +import { USER_SOCIAL_LINKS } from '../../../../../../infra/rest/typings'; + +export type UpdateProfilePayload = updateProfilePayload; + +export interface ProfileFormData { + username: string; + bio: string; + social_links: USER_SOCIAL_LINKS; +} + +export interface ProfileImageUploadProps { + currentImage: string; + fullname: string; + onImageSelect: (file: File) => void; + onUpload: () => Promise; + uploading: boolean; + disabled?: boolean; +} + +export interface SocialLinkFormData { + youtube: string; + facebook: string; + twitter: string; + github: string; + instagram: string; + website: string; +} + +export interface SocialLinkInputProps { + formData: USER_SOCIAL_LINKS; + onChange: (e: React.ChangeEvent) => void; +} diff --git a/client/src/modules/settings/v1/components/no-results-found.tsx b/client/src/modules/settings/v1/components/no-results-found.tsx new file mode 100644 index 000000000..9119271b2 --- /dev/null +++ b/client/src/modules/settings/v1/components/no-results-found.tsx @@ -0,0 +1,52 @@ +import { Box, Button } from '@mui/material'; +import SearchOffOutlinedIcon from '@mui/icons-material/SearchOffOutlined'; +import A2ZTypography from '../../../../shared/components/atoms/typography'; + +const NoResultsFound = ({ + searchTerm, + handleOnClearClick, +}: { + searchTerm: string; + handleOnClearClick: () => void; +}) => { + return ( + + + + {searchTerm && ( + + )} + + ); +}; + +export default NoResultsFound; diff --git a/client/src/modules/settings/v1/components/settings-header.tsx b/client/src/modules/settings/v1/components/settings-header.tsx new file mode 100644 index 000000000..901e9e282 --- /dev/null +++ b/client/src/modules/settings/v1/components/settings-header.tsx @@ -0,0 +1,65 @@ +import { Box, IconButton, useTheme } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import A2ZTypography from '../../../../shared/components/atoms/typography'; +import { HEADER_HEIGHT } from '../../../../shared/components/organisms/header/constants'; +import { useDevice } from '../../../../shared/hooks/use-device'; +import { useCustomNavigate } from '../../../../shared/hooks/use-custom-navigate'; +import { ROUTES_V1 } from '../../../app/routes/constants/routes'; + +interface SettingsHeaderProps { + title: string; +} + +const SettingsHeader = ({ title }: SettingsHeaderProps) => { + const theme = useTheme(); + const { isMobileOrTablet } = useDevice(); + const navigate = useCustomNavigate(); + + const handleBackClick = () => { + navigate({ pathname: ROUTES_V1.SETTINGS }); + }; + + return ( + + {isMobileOrTablet && ( + + + + )} + + + ); +}; + +export default SettingsHeader; diff --git a/client/src/modules/settings/v1/components/settings-tab.tsx b/client/src/modules/settings/v1/components/settings-tab.tsx new file mode 100644 index 000000000..024c7a84a --- /dev/null +++ b/client/src/modules/settings/v1/components/settings-tab.tsx @@ -0,0 +1,163 @@ +import { Box, ButtonBase, useTheme } from '@mui/material'; +import LockIcon from '@mui/icons-material/Lock'; +import { ROUTES_V1 } from '../../../app/routes/constants/routes'; +import { SettingTabType } from '../typings'; +import A2ZTypography from '../../../../shared/components/atoms/typography'; +import { useCustomNavigate } from '../../../../shared/hooks/use-custom-navigate'; + +const SettingsTab = ({ + setting, + index, + filteredSettings, +}: { + setting: SettingTabType; + index: number; + filteredSettings: SettingTabType[]; +}) => { + const theme = useTheme(); + const navigate = useCustomNavigate(); + const { name, description, icon, path, locked, isNew, newText, feature } = + setting; + const absolutePath = `${ROUTES_V1.SETTINGS}${path}`; + const isTabActive = window.location.pathname.includes(absolutePath); + const isLocked = locked || (feature !== undefined && feature !== ''); + + return ( + + key={index} + component="div" + sx={{ + width: '100%', + p: 2, + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'flex-start', + rowGap: 0.25, + borderBottom: + index < filteredSettings.length - 1 + ? `1px solid ${theme.palette.divider}` + : 'none', + bgcolor: isTabActive + ? theme.palette.mode === 'dark' + ? 'rgba(59, 130, 246, 0.1)' + : 'rgba(240, 245, 240, 1)' + : 'transparent', + boxShadow: 'none', + outline: 'none', + transition: 'background-color 200ms ease-in-out', + '&:hover': { + bgcolor: isTabActive + ? theme.palette.mode === 'dark' + ? 'rgba(59, 130, 246, 0.15)' + : 'rgba(240, 245, 240, 0.68)' + : 'action.hover', + boxShadow: 'none', + outline: 'none', + }, + }} + onClick={() => { + if (locked) { + return; + } + navigate({ pathname: absolutePath }); + }} + > + + + + {icon} + {isLocked && ( + + )} + + + + {isNew && ( + + {newText ?? 'NEW'} + + )} + + {description && ( + + )} + + ); +}; + +export default SettingsTab; diff --git a/client/src/modules/settings/v1/constants/index.ts b/client/src/modules/settings/v1/constants/index.ts new file mode 100644 index 000000000..99189b3f1 --- /dev/null +++ b/client/src/modules/settings/v1/constants/index.ts @@ -0,0 +1 @@ +export const SETTINGS_SIDEBAR_WIDTH = 300; // in pixels diff --git a/client/src/modules/settings/v1/hooks/get-settings.tsx b/client/src/modules/settings/v1/hooks/get-settings.tsx new file mode 100644 index 000000000..6ccdb6a4b --- /dev/null +++ b/client/src/modules/settings/v1/hooks/get-settings.tsx @@ -0,0 +1,55 @@ +/* eslint-disable react-refresh/only-export-components */ +import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'; +import { ROUTES_V1 } from '../../../app/routes/constants/routes'; +import { SettingTabType } from '../typings'; +import { Navigate, Route } from 'react-router-dom'; +import { ProtectedRoute } from '../../../app/routes/auth-routes/protected-route'; +import { lazy } from 'react'; + +export const ProfileLazyComponentV1 = lazy( + () => import('../../modules/profile/v1') +); + +export const getSettings = ({ + isMobileOrTablet, +}: { + isMobileOrTablet: boolean; +}) => { + const settings: SettingTabType[] = [ + { + id: 'profile', + icon: , + path: ROUTES_V1.SETTINGS_PROFILE, + name: 'Your profile', + description: 'Edit your personal details', + }, + ]; + + const routes: React.ReactNode[] = [ + + } + />, + + !isMobileOrTablet && ( + + } + /> + ), + ]; + + return { + settings: settings, + routes: routes, + }; +}; diff --git a/client/src/modules/settings/v1/hooks/index.ts b/client/src/modules/settings/v1/hooks/index.ts new file mode 100644 index 000000000..459d583b6 --- /dev/null +++ b/client/src/modules/settings/v1/hooks/index.ts @@ -0,0 +1,65 @@ +import { useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { getSettings } from './get-settings'; +import { ROUTES_V1 } from '../../../app/routes/constants/routes'; +import { useDevice } from '../../../../shared/hooks/use-device'; + +const useSettingsV1 = () => { + const location = useLocation(); + const { isMobileOrTablet } = useDevice(); + const [searchTerm, setSearchTerm] = useState(''); + + const handleClearSearch = () => { + setSearchTerm(''); + }; + + const { settings, routes } = getSettings({ isMobileOrTablet }); + const filteredSettings = useMemo(() => { + if (!searchTerm) return settings; + return settings.filter(setting => + setting.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [settings, searchTerm]); + + const activeSetting = useMemo(() => { + const currentPath = location.pathname; + const settingsBasePath = ROUTES_V1.SETTINGS; + + const normalizedCurrent = currentPath.replace(/\/$/, ''); + const normalizedBase = settingsBasePath.replace(/\/$/, ''); + + if (normalizedCurrent === normalizedBase) { + return undefined; + } + + return settings.find(setting => { + const settingPath = setting.path.startsWith('/') + ? setting.path + : `/${setting.path}`; + const absolutePath = `${normalizedBase}${settingPath}`; + const normalizedAbsolute = absolutePath.replace(/\/$/, ''); + + return ( + normalizedCurrent === normalizedAbsolute || + normalizedCurrent.startsWith(`${normalizedAbsolute}/`) + ); + }); + }, [location.pathname, settings]); + + const isSettingsDetailPage = useMemo(() => { + return activeSetting !== undefined; + }, [activeSetting]); + + return { + searchTerm, + setSearchTerm, + handleClearSearch, + settings, + routes, + filteredSettings, + activeSetting, + isSettingsDetailPage, + }; +}; + +export default useSettingsV1; diff --git a/client/src/modules/settings/v1/index.tsx b/client/src/modules/settings/v1/index.tsx index 44523f5e8..c08a928b3 100644 --- a/client/src/modules/settings/v1/index.tsx +++ b/client/src/modules/settings/v1/index.tsx @@ -1,8 +1,169 @@ +import { Box, useTheme } from '@mui/material'; +import { Routes } from 'react-router-dom'; +import Searchbar from '../../../shared/components/molecules/searchbar'; +import { SETTINGS_SIDEBAR_WIDTH } from './constants'; +import useSettingsV1 from './hooks'; +import NoResultsFound from './components/no-results-found'; +import SettingsTab from './components/settings-tab'; +import SettingsHeader from './components/settings-header'; +import { useDevice } from '../../../shared/hooks/use-device'; + const Settings = () => { + const theme = useTheme(); + const { isMobileOrTablet } = useDevice(); + const { + searchTerm, + setSearchTerm, + handleClearSearch, + filteredSettings, + routes, + activeSetting, + isSettingsDetailPage, + } = useSettingsV1(); + + // On mobile, show sidebar only when not on a detail page + const showSidebarOnMobile = !isMobileOrTablet || !isSettingsDetailPage; + return ( -
-

Settings

-
+ + + + + + + + {filteredSettings.length === 0 ? ( + + ) : ( + filteredSettings.map((setting, index) => ( + + )) + )} + + + + + {activeSetting && } + + {routes} + + + ); }; diff --git a/client/src/modules/settings/v1/typings/index.ts b/client/src/modules/settings/v1/typings/index.ts new file mode 100644 index 000000000..5788dc1e9 --- /dev/null +++ b/client/src/modules/settings/v1/typings/index.ts @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; + +export interface SettingTabType { + id: string; + icon: ReactNode; + path: string; + name: string; + description?: string; + locked?: boolean; + isNew?: boolean; + newText?: string; + disabled?: boolean; + feature?: string; +} diff --git a/client/src/shared/components/molecules/searchbar/index.tsx b/client/src/shared/components/molecules/searchbar/index.tsx new file mode 100644 index 000000000..390a4bfbf --- /dev/null +++ b/client/src/shared/components/molecules/searchbar/index.tsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react'; +import { + Box, + IconButton, + useTheme, + SxProps, + Theme, + Drawer, +} from '@mui/material'; +import { CloseRounded, Search as SearchIcon } from '@mui/icons-material'; +import Loader from '../../molecules/loader'; +import InputBox from '../../atoms/input-box'; +import { useDevice } from '../../../hooks/use-device'; + +interface SearchbarProps { + customCSS?: Record; + placeholder?: string; + showLoading?: boolean; + onSearch: (value: string) => void; + searchTerm: string; + handleOnClearClick: () => void; + variant?: 'fullWidth' | 'iconButton' | 'auto'; + sx?: SxProps; + autoFocus?: boolean; +} + +const Searchbar: React.FC = ({ + customCSS = {}, + placeholder = 'Search settings...', + showLoading = false, + onSearch, + searchTerm, + handleOnClearClick, + variant = 'auto', + sx = {}, + autoFocus = true, +}) => { + const theme = useTheme(); + const { isMobile } = useDevice(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const handleInputChange = (value: string) => { + onSearch(value); + }; + + const shouldShowIconButton = + variant === 'iconButton' || (variant === 'auto' && isMobile); + + const handleIconButtonClick = () => { + setIsDrawerOpen(true); + }; + + const handleDrawerClose = () => { + setIsDrawerOpen(false); + }; + + const searchbarContent = ( + + + } + sx={{ + flex: 1, + '.MuiFormControl-root': { margin: '0' }, + '.MuiInputBase-root': { + bgcolor: 'background.paper', + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + '&::before': { border: 'none' }, + '&::after': { border: 'none' }, + '&:hover:not(.Mui-disabled)': { + borderColor: 'primary.main', + }, + '&.Mui-focused': { + borderColor: 'primary.main', + }, + 'input::placeholder, textArea::placeholder': { + color: 'text.secondary', + }, + input: { + color: 'text.primary', + py: 1, + }, + ...customCSS, + }, + }} + value={searchTerm} + slotProps={{ + htmlInput: { + onChange: (e: React.ChangeEvent) => + handleInputChange(e.target.value), + autoFocus: autoFocus && !shouldShowIconButton, + }, + }} + /> + {showLoading && ( + + + + )} + {searchTerm && ( + + + + )} + + ); + + if (shouldShowIconButton) { + return ( + <> + + + + + {searchbarContent} + + + ); + } + + return searchbarContent; +}; + +export default Searchbar; diff --git a/client/src/shared/hooks/use-auth.ts b/client/src/shared/hooks/use-auth.ts index 339bd8b13..ac5037c51 100644 --- a/client/src/shared/hooks/use-auth.ts +++ b/client/src/shared/hooks/use-auth.ts @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from 'react'; import { UserAtom } from '../../infra/states/user'; import { TokenAtom } from '../../infra/states/auth'; import { refreshToken } from '../../infra/rest/apis/auth'; +import { getCurrentUser } from '../../infra/rest/apis/user'; import { getAccessToken, removeFromLocal, @@ -15,28 +16,37 @@ export const useAuth = () => { const setUser = useSetAtom(UserAtom); const [initialized, setInitialized] = useState(false); - // Initialize tokens from localStorage on app start + // Initialize tokens and fetch user data from server on app start useEffect(() => { - const accessToken = getAccessToken(); + const initializeAuth = async () => { + const accessToken = getAccessToken(); + + if (accessToken) { + setToken(accessToken); + try { + const response = await getCurrentUser(); + if (response.status === 'success' && response.data) { + setUser(response.data); + } + } catch (error) { + // If fetching user fails, token might be invalid + // Don't clear token here - let the interceptor handle it + console.error('Failed to fetch current user:', error); + } + } - if (accessToken) { - setToken(accessToken); - } - // Mark auth as initialized after syncing from storage so components can wait - setInitialized(true); - }, [setToken]); + // Mark auth as initialized after syncing from storage so components can wait + setInitialized(true); + }; - // Sync tokens with localStorage when they change (for local state management) - useEffect(() => { - if (token) { - setAccessToken(token); - } - }, [token]); + initializeAuth(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const clearToken = (): void => { + const clearToken = useCallback((): void => { removeFromLocal(TOKEN_CONFIG.ACCESS_TOKEN_NAME); setToken(null); - }; + }, [setToken]); const logout = useCallback(() => { setToken(null); diff --git a/client/src/shared/hooks/use-device.ts b/client/src/shared/hooks/use-device.ts index 9693a6efe..38ffe8949 100644 --- a/client/src/shared/hooks/use-device.ts +++ b/client/src/shared/hooks/use-device.ts @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'; const breakpoints = { mobile: 640, - tablet: 1024, + tablet: 900, + laptop: 1024, }; export function useDevice() { @@ -17,6 +18,9 @@ export function useDevice() { return { isMobile: width < breakpoints.mobile, isTablet: width >= breakpoints.mobile && width < breakpoints.tablet, - isDesktop: width >= breakpoints.tablet, + isLaptop: width >= breakpoints.tablet && width < breakpoints.laptop, + isDesktop: width >= breakpoints.laptop, + + isMobileOrTablet: width < breakpoints.tablet, }; } diff --git a/server/src/controllers/user/get-current-user.js b/server/src/controllers/user/get-current-user.js new file mode 100644 index 000000000..dbfb680e9 --- /dev/null +++ b/server/src/controllers/user/get-current-user.js @@ -0,0 +1,43 @@ +/** + * GET /api/user/me - Get current authenticated user + * @returns {Object} Current user profile + */ + +import USER from '../../models/user.model.js'; +import { sendResponse } from '../../utils/response.js'; + +const getCurrentUser = async (req, res) => { + try { + const { user_id } = req.user; + + if (!user_id) { + return sendResponse(res, 401, 'User ID not found in token'); + } + + const user = await USER.findById(user_id) + .select( + '-personal_info.password -updatedAt -projects -collaborated_projects -collections' + ) + .populate({ + path: 'personal_info.subscriber_id', + select: 'email -_id', + }) + .lean(); + + if (!user) { + return sendResponse(res, 404, 'User not found'); + } + + // Extract email and remove subscriber_id + if (user.personal_info?.subscriber_id?.email) { + user.personal_info.email = user.personal_info.subscriber_id.email; + delete user.personal_info.subscriber_id; + } + + return sendResponse(res, 200, 'User fetched successfully', user); + } catch (err) { + return sendResponse(res, 500, err.message || 'Internal Server Error'); + } +}; + +export default getCurrentUser; diff --git a/server/src/routes/api/user.routes.js b/server/src/routes/api/user.routes.js index 0b8074ca2..342ba5174 100644 --- a/server/src/routes/api/user.routes.js +++ b/server/src/routes/api/user.routes.js @@ -3,6 +3,7 @@ import express from 'express'; import authenticateUser from '../../middlewares/auth.middleware.js'; import getProfile from '../../controllers/user/get-profile.js'; +import getCurrentUser from '../../controllers/user/get-current-user.js'; import searchUser from '../../controllers/user/search-user.js'; import updateProfile from '../../controllers/user/update-profile.js'; import updateProfileImg from '../../controllers/user/update-profile-img.js'; @@ -11,6 +12,7 @@ const userRoutes = express.Router(); userRoutes.get('/search', searchUser); userRoutes.get('/profile', getProfile); +userRoutes.get('/me', authenticateUser, getCurrentUser); userRoutes.patch('/update-profile-img', authenticateUser, updateProfileImg); userRoutes.patch('/update-profile', authenticateUser, updateProfile);