diff --git a/client/.vercelignore b/client/.vercelignore index 051e59ff4..2627ca565 100644 --- a/client/.vercelignore +++ b/client/.vercelignore @@ -1,8 +1,6 @@ -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/* +projects/* +server/* +client/src/modules/edit-profile/* +client/src/modules/notification/* +src/modules/edit-profile/* +src/modules/notification/* diff --git a/client/src/App.tsx b/client/src/App.tsx index b82e81a15..36bd99e99 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,7 +13,7 @@ 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 ManageProjects from './modules/manage-projects'; // import EditProfile from "./modules/edit-profile"; // import Notifications from "./modules/notification"; @@ -35,7 +35,7 @@ function App() { } /> }> - {/* } /> */} + } /> {/* } /> */} }> diff --git a/client/src/modules/manage-projects/components/draft-projects.tsx b/client/src/modules/manage-projects/components/draft-projects.tsx index 731ac0977..432c8a2a9 100644 --- a/client/src/modules/manage-projects/components/draft-projects.tsx +++ b/client/src/modules/manage-projects/components/draft-projects.tsx @@ -1,3 +1,7 @@ +import { Link } from 'react-router-dom'; +import { Box, Typography, Button, Card, CardContent } from '@mui/material'; +import { Project, deleteProject } from './utils'; + const ManageDraftProjectPost = ({ project }: { project: Project }) => { const { title, des } = project; let { index = 0 } = project; @@ -5,44 +9,53 @@ const ManageDraftProjectPost = ({ project }: { project: Project }) => { index++; return ( -
-

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

- -
-

{title}

- -

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

- -
- - Edit - - - -
-
- -
- -
-
+ + + {index < 10 ? '0' + index : index} + + + + + + {title} + + + + {des?.length ? des : '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..d09a95c52 100644 --- a/client/src/modules/manage-projects/components/publish-projects.tsx +++ b/client/src/modules/manage-projects/components/publish-projects.tsx @@ -1,12 +1,18 @@ 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'; +import { UserAtom } from '../../../infra/states/user'; +import { + Box, + Typography, + Button, + Card, + CardContent, + CardActions, + Avatar, +} from '@mui/material'; +import { Project, deleteProject } from './utils'; interface ProjectStats { total_likes: number; @@ -15,168 +21,143 @@ interface ProjectStats { [key: string]: number; // Allow dynamic key access } -interface Project { - _id?: string; - project_id: string; - title: string; - des?: string; - banner?: string; - publishedAt: string; - activity?: ProjectStats; - index?: number; - setStateFunc?: (value: SetStateAction) => void; -} +// Project type and deleteProject helper are moved to ./utils to avoid exporting non-component +// functions from this file (react-refresh requirement). const ProjectStats = ({ stats }: { stats: ProjectStats }) => { return ( -
- {Object.keys(stats).map((key, i) => { - return !key.includes('parent') ? ( -
+ {Object.keys(stats).map((key, i) => + !key.includes('parent') ? ( + -

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

-

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

-
- ) : ( - '' - ); - })} -
+ + + ) : null + )} + ); }; -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'); - }); -}; +// deleteProject is imported from ./utils const ManagePublishedProjectCard = ({ project }: { project: Project }) => { const { banner, project_id, title, publishedAt, activity } = project; const [user] = useAtom(UserAtom); - const access_token = user.access_token || ''; + // USER_DB_STATE doesn't include access_token; if present (legacy/session), read safely + const access_token = user + ? ((user as unknown as { access_token?: string }).access_token ?? '') + : ''; const [showStat, setShowStat] = useState(false); return ( - <> -
- - -
-
- + + + {banner ? ( + + ) : ( + + )} + + + {title} - - -

Published on {getDay(publishedAt)}

-
- -
- - Edit - - - - - -
-
- -
- {activity && } -
-
+ + + Published on {getDay(publishedAt)} + + + + + + + + + + + {activity && } + + + {showStat ? ( -
+ {activity && } -
- ) : ( - '' - )} - + + ) : null} + ); }; diff --git a/client/src/modules/manage-projects/components/utils.ts b/client/src/modules/manage-projects/components/utils.ts new file mode 100644 index 000000000..c71801831 --- /dev/null +++ b/client/src/modules/manage-projects/components/utils.ts @@ -0,0 +1,82 @@ +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; +} + +export interface Project { + _id?: string; + project_id: string; + title: string; + des?: string; + banner?: string; + publishedAt: string; + activity?: ProjectStats; + index?: number; + setStateFunc?: (value: SetStateAction) => void; +} + +export 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'); + }); +}; diff --git a/client/src/modules/manage-projects/index.tsx b/client/src/modules/manage-projects/index.tsx index 80b68dd12..714eb88f4 100644 --- a/client/src/modules/manage-projects/index.tsx +++ b/client/src/modules/manage-projects/index.tsx @@ -1,174 +1,191 @@ -import { useAtom, useAtomValue } from 'jotai'; -import { useEffect, useState, useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useEffect } from 'react'; +import { useParams } 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'; - -const ManageProjects = () => { - const [projects, setProjects] = useAtom(AllProjectsAtom); - const [drafts, setDrafts] = useAtom(DraftProjectAtom); +import NoDataMessage from '../../shared/components/atoms/no-data-msg'; +import { useAtom, useAtomValue } from 'jotai'; +import { HomePageProjectsAtom } from '../home/states'; +import BannerProjectCard from '../home/components/banner-project-card'; +import { ProfileAtom } from '../profile/states'; +import { Virtuoso } from 'react-virtuoso'; +import { BannerSkeleton } from '../../shared/components/atoms/skeleton'; +import { UserAtom } from '../../infra/states/user'; +import { Avatar, Box, CircularProgress, Grid } from '@mui/material'; +import useHome from '../home/hooks'; +import AboutUser from '../profile/components/about-user'; +import A2ZTypography from '../../shared/components/atoms/typography'; +import Button from '../../shared/components/atoms/button'; +import useProfile from '../profile/hooks'; + +const Profile = () => { + const { username } = useParams(); const user = useAtomValue(UserAtom); - - const activeTab = useSearchParams()[0].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 profile = useAtomValue(ProfileAtom); + const [projects, setProjects] = useAtom(HomePageProjectsAtom); + const { fetchProjectsByCategory } = useHome(); + const { fetchUserProfile } = useProfile(); useEffect(() => { - if (user.access_token) { - if (projects === null) { - getProjects({ page: 1, draft: false }); - } - if (drafts === null) { - getProjects({ page: 1, draft: true }); - } - } - }, [user.access_token, projects, drafts, query, getProjects]); - - const handleSearch = (e: React.KeyboardEvent) => { - const searchQuery = e.currentTarget.value; - setQuery(searchQuery); - - if (e.keyCode === 13 && searchQuery.length) { - setProjects(null); - setDrafts(null); + if (username !== user?.personal_info.username) { + setProjects([]); } - }; - - const handleChange = (e: React.ChangeEvent) => { - if (!e.currentTarget.value.length) { - setQuery(''); - setProjects(null); - setDrafts(null); + if (projects.length === 0 || profile?.personal_info.username !== username) { + fetchUserProfile(); } - }; + }, [ + username, + user?.personal_info.username, + projects.length, + profile?.personal_info.username, + fetchUserProfile, + setProjects, + ]); + + if (!profile) { + return ( + + + + ); + } return ( - <> -

Manage Projects

-
- - - -
- - + - { - // Published Projects - projects === null ? ( - - ) : projects.results.length ? ( - <> - {projects.results.map((project, i) => { - return ( - - ); - })} - - ({ md: `1px solid ${theme.palette.divider}` }), + }} + > + + + + + + + {user && username === user.personal_info.username ? ( + + ) : undefined} + + + + + + + {/* Left Column (Projects + About Tabs) */} + + + {projects.length ? ( + - - ) : ( - - ) - } - - { - // Draft Projects - drafts === null ? ( - - ) : drafts.results.length ? ( - <> - {drafts.results.map((project, i) => { - return ( - - ); - })} - - ( + + )} + overscan={200} + endReached={() => { + const nextPage = Math.floor(projects.length / 10) + 1; // Assuming page size of 10 + fetchProjectsByCategory({ + page: nextPage, + user_id: profile._id, + }); + }} + components={{ + Footer: () => + !projects || projects.length === 0 ? ( + + ) : null, // FIX ME }} /> - - ) : ( - - ) - } - - + ) : ( + + )} + + + + + + ); }; -export default ManageProjects; +export default Profile; diff --git a/client/src/modules/manage-projects/states/index.ts b/client/src/modules/manage-projects/states/index.ts index e69de29bb..6dfd1c4f0 100644 --- a/client/src/modules/manage-projects/states/index.ts +++ b/client/src/modules/manage-projects/states/index.ts @@ -0,0 +1,13 @@ +import { atom } from 'jotai'; +import { PROJECT_DB_STATE } from '../../../infra/rest/typings'; + +// Minimal pagination type used across the app +export interface PaginatedProjects { + results: PROJECT_DB_STATE[]; + totalDocs: number; + deletedDocCount?: number; + page?: number; +} + +export const AllProjectsAtom = atom(null); +export const DraftProjectAtom = atom(null); diff --git a/client/src/modules/project/components/project-content.tsx b/client/src/modules/project/components/project-content.tsx index c70dc97d1..38f520d90 100644 --- a/client/src/modules/project/components/project-content.tsx +++ b/client/src/modules/project/components/project-content.tsx @@ -1,25 +1,37 @@ -import { useState } from 'react'; -import { Box, Typography, Button } from '@mui/material'; import { OutputBlockData } from '@editorjs/editorjs'; +import React, { useState } from 'react'; +import { Box, Typography, Button } from '@mui/material'; + +interface ProjectContentProps { + block: OutputBlockData; +} + +type EJData = Record | undefined; -const ParagraphBlock = ({ text }: { text: string }) => { +const ParagraphBlock = ({ data }: { data?: EJData }) => { + const text = (data && (data['text'] as string)) || ''; return ; }; -const HeaderBlock = ({ level, text }: { level: number; text: string }) => { +const HeaderBlock = ({ data }: { data?: EJData }) => { + const level = (data && (data['level'] as number)) || 2; const Tag = level === 3 ? 'h3' : 'h2'; const size = level === 3 ? 'h5' : 'h4'; + const text = (data && (data['text'] as string)) || ''; return ( ); }; -const ImageBlock = ({ url, caption }: { url: string; caption: string }) => { +const ImageBlock = ({ data }: { data?: EJData }) => { + const file = data && (data['file'] as EJData); + const url = (file && (file['url'] as string)) || ''; + const caption = (data && (data['caption'] as string)) || ''; return ( { alt={caption} style={{ maxWidth: '100%', borderRadius: '8px' }} /> - {caption && ( + {caption ? ( {caption} - )} + ) : null} ); }; -const QuoteBlock = ({ text, caption }: { text: string; caption: string }) => { +const QuoteBlock = ({ data }: { data?: EJData }) => { + const text = (data && (data['text'] as string)) || ''; + const caption = (data && (data['caption'] as string)) || ''; return ( { {text} - {caption && ( + {caption ? ( {caption} - )} + ) : null} ); }; -const ListBlock = ({ style, items }: { style: string; items: string[] }) => { +const ListBlock = ({ data }: { data?: EJData }) => { + const style = (data && (data['style'] as string)) || 'unordered'; const Tag = style === 'ordered' ? 'ol' : 'ul'; const styleType = style === 'ordered' ? 'decimal' : 'disc'; + const items = (data && (data['items'] as string[])) || []; return ( - + {items.map((item, i) => (
  • { ); }; -const CodeBlock = ({ code, language }: { code: string; language: string }) => { - const codeText = code || ''; +const CodeBlock = ({ data }: { data?: EJData }) => { + const codeText = (data && (data['code'] as string)) || ''; + const language = (data && (data['language'] as string)) || ''; const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -132,28 +153,22 @@ const CodeBlock = ({ code, language }: { code: string; language: string }) => { ); }; -const ProjectContent = ({ block }: { block: OutputBlockData }) => { - const { type, data } = block || {}; +const ProjectContent = ({ block }: ProjectContentProps) => { + const { type, data } = block; switch (type) { case 'paragraph': - return ; + return ; case 'header': - return ; + return ; case 'image': - return ( - - ); + return ; case 'quote': - return ( - - ); + return ; case 'list': - return ; + return ; case 'code': - return ( - - ); + return ; default: return null; } diff --git a/client/src/shared/components/organisms/sidebar/index.tsx b/client/src/shared/components/organisms/sidebar/index.tsx index 7e2a79846..84c5b8407 100644 --- a/client/src/shared/components/organisms/sidebar/index.tsx +++ b/client/src/shared/components/organisms/sidebar/index.tsx @@ -1,7 +1,5 @@ -import { useAtomValue } from 'jotai'; import { useEffect, useState } from 'react'; import { Navigate, NavLink, Outlet, useLocation } from 'react-router-dom'; -import { UserAtom } from '../../../../infra/states/user'; import { useAuth } from '../../../hooks/use-auth'; import { notificationStatus } from '../../../../infra/rest/apis/notification'; @@ -19,6 +17,7 @@ import { Typography, useMediaQuery, useTheme, + CircularProgress, } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; @@ -30,8 +29,7 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; const drawerWidth = 240; const Sidebar = () => { - const user = useAtomValue(UserAtom); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, initialized } = useAuth(); const location = useLocation(); const page = location.pathname.split('/')[2]; @@ -43,7 +41,23 @@ const Sidebar = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); + // TEMP DEBUG: log auth init and token presence to help diagnose routing issues useEffect(() => { + try { + console.debug('Sidebar debug:', { + initialized, + isAuthenticated: isAuthenticated(), + token: localStorage.getItem('access_token'), + }); + } catch (err) { + console.debug('Sidebar debug error', err); + } + }, [initialized]); + + useEffect(() => { + // Only fetch notification status after auth initialization and when authenticated. + if (!initialized || !isAuthenticated()) return; + const fetchNotificationStatus = async () => { const response = await notificationStatus(); if (response.status === 'success' && response.data) { @@ -51,9 +65,27 @@ const Sidebar = () => { } }; fetchNotificationStatus(); - }, []); + }, [initialized, isAuthenticated]); + + // If auth hasn't finished initializing yet, render a small loader to avoid + // premature redirects while the token/user is being restored. + if (!initialized) { + return ( + + + + ); + } - if (!user || !isAuthenticated()) { + // After initialization, if not authenticated, redirect to login. + if (!isAuthenticated()) { return ; }