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 0b2bdf2f..914c30ec 100644
--- a/client/src/modules/app/routes/auth-routes/v1/index.tsx
+++ b/client/src/modules/app/routes/auth-routes/v1/index.tsx
@@ -11,8 +11,8 @@ import {
export default function getRoutesV1() {
const routes = [
}>
diff --git a/client/src/modules/app/routes/constants/routes.ts b/client/src/modules/app/routes/constants/routes.ts
index 01dc37ea..bda6ff06 100644
--- a/client/src/modules/app/routes/constants/routes.ts
+++ b/client/src/modules/app/routes/constants/routes.ts
@@ -1,10 +1,17 @@
+export enum ROUTES_PAGE_V1 {
+ HOME = 'home',
+ SETTINGS = 'settings',
+}
+
export enum ROUTES_V1 {
HOME = '/v1/home',
SETTINGS = '/v1/settings',
- SETTINGS_PROFILE = '/profile',
}
-export enum ROUTES_PAGE_V1 {
- HOME = 'home',
- SETTINGS = 'settings',
+export enum ROUTES_HOME_V1 {
+ PROJECT = '/:project_id',
+}
+
+export enum ROUTES_SETTINGS_V1 {
+ PROFILE = '/profile',
}
diff --git a/client/src/modules/home/modules/index.tsx b/client/src/modules/home/modules/index.tsx
new file mode 100644
index 00000000..df93c4c6
--- /dev/null
+++ b/client/src/modules/home/modules/index.tsx
@@ -0,0 +1,3 @@
+import { lazy } from 'react';
+
+export const ProjectLazyComponentV1 = lazy(() => import('./project/v1'));
diff --git a/client/src/modules/home/modules/project/v1/components/project-content.tsx b/client/src/modules/home/modules/project/v1/components/project-content.tsx
new file mode 100644
index 00000000..8713bba5
--- /dev/null
+++ b/client/src/modules/home/modules/project/v1/components/project-content.tsx
@@ -0,0 +1,232 @@
+import { useState } from 'react';
+import { Box, Button, useTheme } from '@mui/material';
+import { OutputBlockData } from '@editorjs/editorjs';
+import A2ZTypography from '../../../../../../shared/components/atoms/typography';
+
+const ParagraphBlock = ({ text }: { text: string }) => {
+ return (
+
+ );
+};
+
+const HeaderBlock = ({ level, text }: { level: number; text: string }) => {
+ const variant = level === 3 ? 'h5' : 'h4';
+ const component = level === 3 ? 'h3' : 'h2';
+ return (
+
+ );
+};
+
+const ImageBlock = ({ url, caption }: { url: string; caption: string }) => {
+ return (
+
+
+ {caption && (
+
+ )}
+
+ );
+};
+
+const QuoteBlock = ({ text, caption }: { text: string; caption: string }) => {
+ const theme = useTheme();
+ return (
+
+
+ {caption && (
+
+ )}
+
+ );
+};
+
+const ListBlock = ({ style, items }: { style: string; items: string[] }) => {
+ const Tag = style === 'ordered' ? 'ol' : 'ul';
+ const styleType = style === 'ordered' ? 'decimal' : 'disc';
+ return (
+
+ {items.map((item, i) => (
+
+
+
+ ))}
+
+ );
+};
+
+const CodeBlock = ({ code, language }: { code: string; language: string }) => {
+ const theme = useTheme();
+ const codeText = code || '';
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(codeText);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setCopied(false);
+ }
+ };
+
+ return (
+
+ {language && (
+
+ )}
+
+
+ {codeText}
+
+
+ );
+};
+
+const ProjectContent = ({ block }: { block: OutputBlockData }) => {
+ const { type, data } = block || {};
+
+ switch (type) {
+ case 'paragraph':
+ return ;
+ case 'header':
+ return ;
+ case 'image':
+ return (
+
+ );
+ case 'quote':
+ return (
+
+ );
+ case 'list':
+ return ;
+ case 'code':
+ return (
+
+ );
+ default:
+ return null;
+ }
+};
+
+export default ProjectContent;
diff --git a/client/src/modules/project/components/project-interaction.tsx b/client/src/modules/home/modules/project/v1/components/project-interaction.tsx
similarity index 61%
rename from client/src/modules/project/components/project-interaction.tsx
rename to client/src/modules/home/modules/project/v1/components/project-interaction.tsx
index 8cc109c5..89f2ee30 100644
--- a/client/src/modules/project/components/project-interaction.tsx
+++ b/client/src/modules/home/modules/project/v1/components/project-interaction.tsx
@@ -1,21 +1,24 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
-import { IconButton, Box, Divider, Typography, Tooltip } from '@mui/material';
+import { IconButton, Box, Divider, Tooltip } from '@mui/material';
import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
import EditIcon from '@mui/icons-material/Edit';
import XIcon from '@mui/icons-material/X';
-import { UserAtom } from '../../../infra/states/user';
+import { UserAtom } from '../../../../../../infra/states/user';
import { LikedByUserAtom, SelectedProjectAtom } from '../states';
-import { useAuth } from '../../../shared/hooks/use-auth';
-import { likeStatus } from '../../../infra/rest/apis/like';
+import { likeStatus } from '../../../../../../infra/rest/apis/like';
import useProjectInteraction from '../hooks/use-project-interaction';
-import { CommentsWrapperAtom } from '../../../shared/components/organisms/comments-wrapper/states';
+import { CommentsWrapperAtom } from '../../../../../../shared/components/organisms/comments-wrapper/states';
+import A2ZTypography from '../../../../../../shared/components/atoms/typography';
+
+// Module-level refs to prevent duplicate API calls across component instances
+const fetchedProjectIdRef: { current: string | null } = { current: null };
+const fetchingLikeStatusRef: { current: boolean } = { current: false };
const ProjectInteraction = () => {
- const { isAuthenticated } = useAuth();
const user = useAtomValue(UserAtom);
const project = useAtomValue(SelectedProjectAtom);
const [islikedByUser, setLikedByUser] = useAtom(LikedByUserAtom);
@@ -24,28 +27,51 @@ const ProjectInteraction = () => {
useEffect(() => {
const fetchLikeStatus = async () => {
- if (!isAuthenticated() || !project?._id) return;
+ if (!project?._id) {
+ // Reset refs if project is cleared
+ fetchedProjectIdRef.current = null;
+ fetchingLikeStatusRef.current = false;
+ return;
+ }
+
+ // Prevent fetching if we've already fetched for this project or are currently fetching
+ if (
+ fetchedProjectIdRef.current === project._id ||
+ fetchingLikeStatusRef.current
+ ) {
+ return;
+ }
+
+ fetchedProjectIdRef.current = project._id;
+ fetchingLikeStatusRef.current = true;
+
try {
const response = await likeStatus(project._id);
- if (response.is_liked) {
- setLikedByUser(Boolean(response.is_liked));
- }
+ setLikedByUser(Boolean(response.is_liked));
} catch (error) {
console.error('Error fetching like status:', error);
+ // Reset on error to allow retry
+ fetchedProjectIdRef.current = null;
+ } finally {
+ fetchingLikeStatusRef.current = false;
}
};
fetchLikeStatus();
- }, [project?._id, setLikedByUser, isAuthenticated]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [project?._id]);
return (
<>
{/* Left side — Likes & Comments */}
@@ -69,29 +95,33 @@ const ProjectInteraction = () => {
{islikedByUser ? : }
-
- {project?.activity.total_likes}
-
+
{/* Comment Button */}
setCommentsWrapper(prev => !prev)}
sx={{
- bgcolor: theme => theme.palette.action.hover,
+ bgcolor: 'action.hover',
color: 'text.secondary',
transition: 'background-color 0.2s, color 0.2s',
'&:hover': {
- bgcolor: theme => theme.palette.action.selected,
+ bgcolor: 'action.selected',
},
}}
>
-
- {project?.activity.total_comments}
-
+
{/* Right side — Edit & Share */}
@@ -114,9 +144,10 @@ const ProjectInteraction = () => {
{
const setSelectedProject = useSetAtom(SelectedProjectAtom);
- const { fetchProjectsByCategory } = useHome();
+ const { fetchProjectsByCategory } = useHomeV1();
const { fetchComments } = useCommentsWrapper();
const [loading, setLoading] = useState(true);
+ const currentProjectIdRef = useRef(null);
const fetchProject = useCallback(
async (project_id: string) => {
- if (!project_id || project_id.trim() === '') return;
+ if (!project_id || project_id.trim() === '') {
+ setLoading(false);
+ return;
+ }
+
+ // Prevent fetching the same project multiple times
+ if (currentProjectIdRef.current === project_id) {
+ return;
+ }
+
+ currentProjectIdRef.current = project_id;
+ setLoading(true);
try {
const response = await getProjectById(project_id);
if (response.data) {
- // Fetch comments for the project
- await fetchComments({ project_id: response.data._id || '' });
setSelectedProject(response.data);
+ // Fetch comments for the project (reset comments for new project)
+ await fetchComments({
+ project_id: response.data._id || '',
+ reset: true,
+ });
+
// Fetch similar projects
if (response.data.tags && response.data.tags.length > 0) {
await fetchProjectsByCategory({
@@ -37,6 +53,7 @@ const useProject = () => {
} catch (error) {
console.error('Error fetching project:', error);
setLoading(false);
+ currentProjectIdRef.current = null;
}
},
[setSelectedProject, fetchProjectsByCategory, fetchComments]
@@ -44,7 +61,6 @@ const useProject = () => {
return {
loading,
- fetchComments,
fetchProject,
};
};
diff --git a/client/src/modules/project/hooks/use-project-interaction.ts b/client/src/modules/home/modules/project/v1/hooks/use-project-interaction.ts
similarity index 69%
rename from client/src/modules/project/hooks/use-project-interaction.ts
rename to client/src/modules/home/modules/project/v1/hooks/use-project-interaction.ts
index d7e5a776..244cd77e 100644
--- a/client/src/modules/project/hooks/use-project-interaction.ts
+++ b/client/src/modules/home/modules/project/v1/hooks/use-project-interaction.ts
@@ -1,21 +1,16 @@
import { useAtom } from 'jotai';
-import { useAuth } from '../../../shared/hooks/use-auth';
import { LikedByUserAtom, SelectedProjectAtom } from '../states';
-import { useNotifications } from '../../../shared/hooks/use-notification';
-import { likeProject } from '../../../infra/rest/apis/like';
+import { useNotifications } from '../../../../../../shared/hooks/use-notification';
+import { likeProject } from '../../../../../../infra/rest/apis/like';
const useProjectInteraction = () => {
- const { isAuthenticated } = useAuth();
const [project, setProject] = useAtom(SelectedProjectAtom);
const [islikedByUser, setLikedByUser] = useAtom(LikedByUserAtom);
const { addNotification } = useNotifications();
const handleLike = async () => {
- if (!project?._id || !isAuthenticated()) {
- return addNotification({
- message: 'Please login to like this project',
- type: 'error',
- });
+ if (!project?._id) {
+ return;
}
try {
@@ -37,7 +32,7 @@ const useProjectInteraction = () => {
});
} catch (error) {
addNotification({
- message: 'Please login to like this project',
+ message: 'Failed to like this project',
type: 'error',
});
console.error('Like error:', error);
diff --git a/client/src/modules/home/modules/project/v1/index.tsx b/client/src/modules/home/modules/project/v1/index.tsx
new file mode 100644
index 00000000..0109ea00
--- /dev/null
+++ b/client/src/modules/home/modules/project/v1/index.tsx
@@ -0,0 +1,241 @@
+import { useEffect } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import { Box, Button, Avatar, Link as MuiLink } from '@mui/material';
+import { getDay } from '../../../../../shared/utils/date';
+import { useAtomValue } from 'jotai';
+import { SelectedProjectAtom } from './states';
+import BannerProjectCard from '../../../v1/components/banner-project-card';
+import { ProjectLoadingSkeleton } from '../../../../../shared/components/atoms/skeleton';
+import { HomePageProjectsAtom } from '../../../v1/states';
+import useProject from './hooks';
+import CommentsWrapper from '../../../../../shared/components/organisms/comments-wrapper';
+import ProjectInteraction from './components/project-interaction';
+import ProjectContent from './components/project-content';
+import {
+ defaultDarkThumbnail,
+ defaultLightThumbnail,
+} from '../../../../editor/constants';
+import { useA2ZTheme } from '../../../../../shared/hooks/use-theme';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import GitHubIcon from '@mui/icons-material/GitHub';
+import { getAllProjectsResponse } from '../../../../../infra/rest/apis/project/typing';
+import { OutputBlockData } from '@editorjs/editorjs';
+import { CommentsWrapperAtom } from '../../../../../shared/components/organisms/comments-wrapper/states';
+import A2ZTypography from '../../../../../shared/components/atoms/typography';
+
+const Project = () => {
+ const { project_id } = useParams();
+ const { theme: a2zTheme } = useA2ZTheme();
+ const selectedProject = useAtomValue(SelectedProjectAtom);
+ const similarProjects = useAtomValue(HomePageProjectsAtom);
+ const commentsWrapper = useAtomValue(CommentsWrapperAtom);
+ const { fetchProject, loading } = useProject();
+
+ useEffect(() => {
+ if (project_id) {
+ fetchProject(project_id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [project_id]);
+
+ if (loading || !selectedProject) {
+ return ;
+ }
+
+ return (
+ <>
+ {commentsWrapper && }
+
+
+
+ {/* Header Section */}
+
+
+
+
+ {selectedProject.live_url && (
+ }
+ sx={{ flex: { xs: '1 1 100%', sm: '0 0 auto' } }}
+ >
+ Live URL
+
+ )}
+
+ {selectedProject.repository_url && (
+ }
+ sx={{ flex: { xs: '1 1 100%', sm: '0 0 auto' } }}
+ >
+ GitHub
+
+ )}
+
+
+
+ {/* Banner Image */}
+
+
+ {/* Author Info + Publish Date */}
+
+
+
+
+
+
+ @
+
+ {selectedProject.user_id.personal_info.username}
+
+
+
+
+
+
+
+
+ {/* Project Interaction Section */}
+
+
+ {/* Project Content */}
+
+ {selectedProject.content_blocks &&
+ selectedProject.content_blocks[0]?.blocks?.map(
+ (block: OutputBlockData, i: number) => (
+
+
+
+ )
+ )}
+
+
+
+
+ {/* Similar Projects Section */}
+ {similarProjects && similarProjects.length > 0 && (
+
+
+
+ {similarProjects.map(
+ (similarProject: getAllProjectsResponse, i: number) => (
+
+ )
+ )}
+
+ )}
+
+
+ >
+ );
+};
+
+export default Project;
diff --git a/client/src/modules/project/states/index.ts b/client/src/modules/home/modules/project/v1/states/index.ts
similarity index 65%
rename from client/src/modules/project/states/index.ts
rename to client/src/modules/home/modules/project/v1/states/index.ts
index 075d8ed7..a1c9b056 100644
--- a/client/src/modules/project/states/index.ts
+++ b/client/src/modules/home/modules/project/v1/states/index.ts
@@ -1,5 +1,5 @@
import { atom } from 'jotai';
-import { ProjectData } from '../../../infra/rest/apis/project/typing';
+import { ProjectData } from '../../../../../../infra/rest/apis/project/typing';
export const SelectedProjectAtom = atom(null);
diff --git a/client/src/modules/home/routes/index.tsx b/client/src/modules/home/routes/index.tsx
new file mode 100644
index 00000000..7c9e1bed
--- /dev/null
+++ b/client/src/modules/home/routes/index.tsx
@@ -0,0 +1,18 @@
+import { Route } from 'react-router-dom';
+import { ProtectedRoute } from '../../app/routes/auth-routes/protected-route';
+import { ROUTES_HOME_V1 } from '../../app/routes/constants/routes';
+import { ProjectLazyComponentV1 } from '../modules';
+
+export const homeRoutes = () => {
+ const routes: React.ReactNode[] = [
+
+ }
+ />,
+ ];
+
+ return { routes };
+};
diff --git a/client/src/modules/home/v1/components/banner-project-card.tsx b/client/src/modules/home/v1/components/banner-project-card.tsx
index 89fef768..5552e5cd 100644
--- a/client/src/modules/home/v1/components/banner-project-card.tsx
+++ b/client/src/modules/home/v1/components/banner-project-card.tsx
@@ -1,6 +1,6 @@
// TODO: Redesign this component to make it more visually appealing & interactive.
import { Link } from 'react-router-dom';
-import { Avatar, Box, Chip, Stack, Typography, useTheme } from '@mui/material';
+import { Avatar, Box, Chip, Stack, useTheme } from '@mui/material';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import { useA2ZTheme } from '../../../../shared/hooks/use-theme';
import { getDay } from '../../../../shared/utils/date';
@@ -9,6 +9,8 @@ import {
defaultLightThumbnail,
} from '../../../editor/constants';
import { getAllProjectsResponse } from '../../../../infra/rest/apis/project/typing';
+import A2ZTypography from '../../../../shared/components/atoms/typography';
+import { ROUTES_V1 } from '../../../app/routes/constants/routes';
const BannerProjectCard = ({
project,
@@ -31,7 +33,7 @@ const BannerProjectCard = ({
return (
-
- {fullname} @{username}
-
-
- {getDay(publishedAt)}
-
+
+
{/* Title */}
-
- {title}
-
+ />
{/* Description */}
-
- {description}
-
+ />
{/* Tags + Likes */}
@@ -120,10 +127,10 @@ const BannerProjectCard = ({
direction="row"
alignItems="center"
spacing={1}
- color="text.secondary"
+ sx={{ color: 'text.secondary' }}
>
- {total_likes}
+
diff --git a/client/src/modules/home/v1/components/no-banner-project.tsx b/client/src/modules/home/v1/components/no-banner-project.tsx
index 14b10161..7dfa4fb1 100644
--- a/client/src/modules/home/v1/components/no-banner-project.tsx
+++ b/client/src/modules/home/v1/components/no-banner-project.tsx
@@ -1,7 +1,9 @@
import { Link } from 'react-router-dom';
-import { Avatar, Box, Typography, Stack, useTheme } from '@mui/material';
+import { Avatar, Box, Stack, useTheme } from '@mui/material';
import { getTrendingProjectsResponse } from '../../../../infra/rest/apis/project/typing';
import { getDay } from '../../../../shared/utils/date';
+import A2ZTypography from '../../../../shared/components/atoms/typography';
+import { ROUTES_V1 } from '../../../app/routes/constants/routes';
interface NoBannerProjectCardProps {
project: getTrendingProjectsResponse;
@@ -21,7 +23,7 @@ const NoBannerProjectCard = ({ project, index }: NoBannerProjectCardProps) => {
return (
{
}}
>
{/* Index Number */}
-
- {index < 10 ? `0${index + 1}` : index}
-
+ />
{/* Main Content */}
@@ -55,36 +58,42 @@ const NoBannerProjectCard = ({ project, index }: NoBannerProjectCardProps) => {
alt={fullname}
sx={{ width: 24, height: 24 }}
/>
-
- {fullname} @{username}
-
-
+
- {getDay(publishedAt)}
-
+ />
{/* Project Title */}
-
- {title}
-
+ />
);
diff --git a/client/src/modules/home/v1/hooks/index.ts b/client/src/modules/home/v1/hooks/index.ts
index 0446de65..2a4a08f2 100644
--- a/client/src/modules/home/v1/hooks/index.ts
+++ b/client/src/modules/home/v1/hooks/index.ts
@@ -1,62 +1,94 @@
+import { useCallback, useMemo, useState } from 'react';
import { useSetAtom } from 'jotai';
-import { HomePageProjectsAtom, HomePageTrendingProjectsAtom } from '../states';
+import { HomePageProjectsAtom } from '../states';
import {
getAllProjects,
getTrendingProjects,
searchProjects,
} from '../../../../infra/rest/apis/project';
+import { homeRoutes } from '../../routes';
+import { useLocation } from 'react-router-dom';
+import { ROUTES_V1 } from '../../../app/routes/constants/routes';
+import { getTrendingProjectsResponse } from '../../../../infra/rest/apis/project/typing';
-const useHome = () => {
+const useHomeV1 = () => {
+ const { routes } = homeRoutes();
+ const location = useLocation();
const setProjects = useSetAtom(HomePageProjectsAtom);
- const setTrending = useSetAtom(HomePageTrendingProjectsAtom);
- const fetchLatestProjects = async (page = 1) => {
- const response = await getAllProjects(page);
- if (response.data) {
- setProjects(response.data);
- }
- };
+ const [selectedCategory, setSelectedCategory] = useState(null);
+ const [trendingProjects, setTrendingProjects] = useState<
+ getTrendingProjectsResponse[]
+ >([]);
+
+ const isHomePage = useMemo(() => {
+ const currentPath = location.pathname;
+ const homeBasePath = ROUTES_V1.HOME;
+ return currentPath === homeBasePath;
+ }, [location.pathname]);
+
+ const fetchLatestProjects = useCallback(
+ async (page = 1) => {
+ if (!isHomePage) return;
+ const response = await getAllProjects(page);
+ if (response.data) {
+ setProjects(response.data);
+ }
+ },
+ [setProjects]
+ );
- const fetchTrendingProjects = async () => {
+ const fetchTrendingProjects = useCallback(async () => {
+ if (!isHomePage) return;
const response = await getTrendingProjects();
if (response.data) {
- setTrending(response.data);
+ setTrendingProjects(response.data);
}
- };
+ }, [setTrendingProjects]);
- const fetchProjectsByCategory = async ({
- tag,
- query,
- user_id,
- page = 1,
- limit = 10,
- rmv_project_by_id,
- }: {
- tag?: string;
- query?: string;
- user_id?: string;
- page?: number;
- limit?: number;
- rmv_project_by_id?: string;
- }) => {
- const response = await searchProjects({
+ const fetchProjectsByCategory = useCallback(
+ async ({
tag,
query,
user_id,
- page,
- limit,
+ page = 1,
+ limit = 10,
rmv_project_by_id,
- });
- if (response.data) {
- setProjects(response.data);
- }
- };
+ }: {
+ tag?: string;
+ query?: string;
+ user_id?: string;
+ page?: number;
+ limit?: number;
+ rmv_project_by_id?: string;
+ }) => {
+ if (!isHomePage) return;
+ const response = await searchProjects({
+ tag,
+ query,
+ user_id,
+ page,
+ limit,
+ rmv_project_by_id,
+ });
+ if (response.data) {
+ setProjects(response.data);
+ }
+ },
+ [setProjects]
+ );
return {
+ routes,
+ isHomePage,
+ selectedCategory,
+ setSelectedCategory,
+ trendingProjects,
+ setTrendingProjects,
fetchLatestProjects,
fetchTrendingProjects,
fetchProjectsByCategory,
};
};
-export default useHome;
+export default useHomeV1;
diff --git a/client/src/modules/home/v1/index.tsx b/client/src/modules/home/v1/index.tsx
index 1fca85c8..d7a99de9 100644
--- a/client/src/modules/home/v1/index.tsx
+++ b/client/src/modules/home/v1/index.tsx
@@ -1,16 +1,13 @@
import { Box, Stack } from '@mui/material';
+import { Routes } from 'react-router-dom';
import A2ZTypography from '../../../shared/components/atoms/typography';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import { categories } from './constants';
import { CategoryButton } from './components/category-button';
import InPageNavigation from '../../../shared/components/molecules/page-navigation';
import NoBannerProjectCard from './components/no-banner-project';
-import { useAtom, useAtomValue } from 'jotai';
-import {
- HomePageProjectsAtom,
- HomePageStateAtom,
- HomePageTrendingProjectsAtom,
-} from './states';
+import { useAtomValue } from 'jotai';
+import { HomePageProjectsAtom } from './states';
import BannerProjectCard from './components/banner-project-card';
import NoDataMessageBox from '../../../shared/components/atoms/no-data-msg';
import {
@@ -18,160 +15,195 @@ import {
NoBannerSkeleton,
} from '../../../shared/components/atoms/skeleton';
import { useEffect } from 'react';
-import useHome from './hooks';
+import useHomeV1 from './hooks';
import { Virtuoso } from 'react-virtuoso';
+import Navbar from '../../../shared/components/organisms/navbar';
const Home = () => {
- const [pageState, setPageState] = useAtom(HomePageStateAtom);
const projects = useAtomValue(HomePageProjectsAtom);
- const trending = useAtomValue(HomePageTrendingProjectsAtom);
const {
+ routes,
+ isHomePage,
+ selectedCategory,
+ setSelectedCategory,
+ trendingProjects,
fetchLatestProjects,
fetchTrendingProjects,
fetchProjectsByCategory,
- } = useHome();
+ } = useHomeV1();
useEffect(() => {
- if (pageState === 'home') {
- fetchLatestProjects();
- } else if (pageState !== 'trending') {
- fetchProjectsByCategory({ tag: pageState });
+ // Only fetch projects if not on project page
+ if (isHomePage) {
+ if (!selectedCategory) {
+ fetchLatestProjects();
+ } else if (selectedCategory !== 'trending') {
+ fetchProjectsByCategory({ tag: selectedCategory });
+ }
+ fetchTrendingProjects();
}
- fetchTrendingProjects();
}, [
- pageState,
+ selectedCategory,
fetchLatestProjects,
fetchProjectsByCategory,
fetchTrendingProjects,
+ isHomePage,
]);
- return (
-
- {/* Latest projects */}
-
-
- {projects.length ? (
- (
-
- )}
- overscan={200}
- endReached={() => {
- const nextPage = Math.floor(projects.length / 10) + 1; // Assuming page size of 10
- if (pageState === 'home') {
- fetchLatestProjects(nextPage);
- } else if (pageState !== 'trending') {
- fetchProjectsByCategory({ page: nextPage, tag: pageState });
- }
- }}
- components={{
- Footer: () =>
- !projects || projects.length === 0 ? (
-
- ) : null, // FIX ME
- }}
- />
- ) : (
-
- )}
- {trending && trending.length === 0 ? ( // FIX ME
-
- ) : trending && trending.length ? (
- trending.map((project, i) => {
- return (
-
- );
- })
- ) : (
-
- )}
-
+ if (!isHomePage) {
+ return (
+
+ {routes}
+ );
+ }
+
+ return (
+ <>
+
- {/* filters and trending projects */}
`1px solid ${theme.palette.divider}`,
- pl: 4,
- pt: 1,
- display: { xs: 'none', md: 'block' },
+ minHeight: 'calc(100vh - 150px)',
+ display: 'flex',
+ justifyContent: 'center',
+ gap: { xs: 0, md: 5 },
+ p: 3,
}}
>
-
-
-
-
-
- {categories.map((category, i) => {
+ {/* Latest projects */}
+
+
+ {projects.length ? (
+ (
+
+ )}
+ overscan={200}
+ endReached={() => {
+ const nextPage = Math.floor(projects.length / 10) + 1; // Assuming page size of 10
+ if (!selectedCategory) {
+ fetchLatestProjects(nextPage);
+ } else if (selectedCategory !== 'trending') {
+ fetchProjectsByCategory({
+ page: nextPage,
+ tag: selectedCategory,
+ });
+ }
+ }}
+ components={{
+ Footer: () =>
+ !projects || projects.length === 0 ? (
+
+ ) : null, // FIX ME
+ }}
+ />
+ ) : (
+
+ )}
+ {trendingProjects && trendingProjects.length === 0 ? ( // FIX ME
+
+ ) : trendingProjects && trendingProjects.length ? (
+ trendingProjects.map((project, i) => {
return (
- {
- setPageState(pageState === category ? 'home' : category);
- }}
- >
- {category}
-
+
);
- })}
+ })
+ ) : (
+
+ )}
+
+
+
+ {/* filters and trending projects */}
+ `1px solid ${theme.palette.divider}`,
+ pl: 4,
+ pt: 1,
+ display: { xs: 'none', md: 'block' },
+ }}
+ >
+
+
+
+
+
+ {categories.map((category, i) => {
+ return (
+ {
+ setSelectedCategory(
+ selectedCategory === category ? null : category
+ );
+ }}
+ >
+ {category}
+
+ );
+ })}
+
-
-
-
-
-
+
+
+
+
- {trending && trending.length === 0 ? ( // FIX ME
-
- ) : trending && trending.length ? (
- trending.map((project, i) => {
- return (
-
- );
- })
- ) : (
-
- )}
-
-
+ {trendingProjects && trendingProjects.length === 0 ? ( // FIX ME
+
+ ) : trendingProjects && trendingProjects.length ? (
+ trendingProjects.map((project, i) => {
+ return (
+
+ );
+ })
+ ) : (
+
+ )}
+
+
+
-
+ >
);
};
diff --git a/client/src/modules/home/v1/states/index.ts b/client/src/modules/home/v1/states/index.ts
index 847c6d2e..554183cf 100644
--- a/client/src/modules/home/v1/states/index.ts
+++ b/client/src/modules/home/v1/states/index.ts
@@ -1,13 +1,4 @@
import { atom } from 'jotai';
-import {
- getAllProjectsResponse,
- getTrendingProjectsResponse,
-} from '../../../../infra/rest/apis/project/typing';
-
-export const HomePageStateAtom = atom('home');
+import { getAllProjectsResponse } from '../../../../infra/rest/apis/project/typing';
export const HomePageProjectsAtom = atom([]);
-
-export const HomePageTrendingProjectsAtom = atom(
- []
-);
diff --git a/client/src/modules/manage-projects/components/publish-projects.tsx b/client/src/modules/manage-projects/components/publish-projects.tsx
index 4da7d8cb..5f0d2c35 100644
--- a/client/src/modules/manage-projects/components/publish-projects.tsx
+++ b/client/src/modules/manage-projects/components/publish-projects.tsx
@@ -5,6 +5,7 @@ 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 { ROUTES_V1 } from '../../app/routes/constants/routes';
import {
PublishedProjectsAtom,
ManageProjectsPaginationState,
@@ -152,7 +153,7 @@ const ManagePublishedProjectCard = ({
) : (
{
const { username } = useParams();
const setProfile = useSetAtom(ProfileAtom);
- const { fetchProjectsByCategory } = useHome();
+ const { fetchProjectsByCategory } = useHomeV1();
const fetchUserProfile = useCallback(async () => {
if (!username) return;
diff --git a/client/src/modules/profile/index.tsx b/client/src/modules/profile/index.tsx
index 74eccace..935c354c 100644
--- a/client/src/modules/profile/index.tsx
+++ b/client/src/modules/profile/index.tsx
@@ -10,7 +10,7 @@ import { Virtuoso } from 'react-virtuoso';
import { BannerSkeleton } from '../../shared/components/atoms/skeleton';
import { UserAtom } from '../../infra/states/user';
import { Avatar, Box, CircularProgress } from '@mui/material';
-import useHome from '../home/v1/hooks';
+import useHomeV1 from '../home/v1/hooks';
import AboutUser from './components/about-user';
import A2ZTypography from '../../shared/components/atoms/typography';
import Button from '../../shared/components/atoms/button';
@@ -21,7 +21,7 @@ const Profile = () => {
const user = useAtomValue(UserAtom);
const profile = useAtomValue(ProfileAtom);
const [projects, setProjects] = useAtom(HomePageProjectsAtom);
- const { fetchProjectsByCategory } = useHome();
+ const { fetchProjectsByCategory } = useHomeV1();
const { fetchUserProfile } = useProfile();
useEffect(() => {
diff --git a/client/src/modules/project/components/project-content.tsx b/client/src/modules/project/components/project-content.tsx
deleted file mode 100644
index c70dc97d..00000000
--- a/client/src/modules/project/components/project-content.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import { useState } from 'react';
-import { Box, Typography, Button } from '@mui/material';
-import { OutputBlockData } from '@editorjs/editorjs';
-
-const ParagraphBlock = ({ text }: { text: string }) => {
- return ;
-};
-
-const HeaderBlock = ({ level, text }: { level: number; text: string }) => {
- const Tag = level === 3 ? 'h3' : 'h2';
- const size = level === 3 ? 'h5' : 'h4';
- return (
-
- );
-};
-
-const ImageBlock = ({ url, caption }: { url: string; caption: string }) => {
- return (
-
-
- {caption && (
-
- {caption}
-
- )}
-
- );
-};
-
-const QuoteBlock = ({ text, caption }: { text: string; caption: string }) => {
- return (
-
-
- {text}
-
- {caption && (
-
- {caption}
-
- )}
-
- );
-};
-
-const ListBlock = ({ style, items }: { style: string; items: string[] }) => {
- const Tag = style === 'ordered' ? 'ol' : 'ul';
- const styleType = style === 'ordered' ? 'decimal' : 'disc';
- return (
-
- {items.map((item, i) => (
-
-
-
- ))}
-
- );
-};
-
-const CodeBlock = ({ code, language }: { code: string; language: string }) => {
- const codeText = code || '';
- const [copied, setCopied] = useState(false);
-
- const handleCopy = async () => {
- try {
- await navigator.clipboard.writeText(codeText);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- setCopied(false);
- }
- };
-
- return (
-
- {language ? (
-
- {language}
-
- ) : null}
-
-
- {codeText}
-
-
- );
-};
-
-const ProjectContent = ({ block }: { block: OutputBlockData }) => {
- const { type, data } = block || {};
-
- switch (type) {
- case 'paragraph':
- return ;
- case 'header':
- return ;
- case 'image':
- return (
-
- );
- case 'quote':
- return (
-
- );
- case 'list':
- return ;
- case 'code':
- return (
-
- );
- default:
- return null;
- }
-};
-
-export default ProjectContent;
diff --git a/client/src/modules/project/index.tsx b/client/src/modules/project/index.tsx
deleted file mode 100644
index ea5577b3..00000000
--- a/client/src/modules/project/index.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import { useEffect } from 'react';
-import { Link, useParams } from 'react-router-dom';
-import {
- Box,
- Typography,
- Button,
- Avatar,
- Link as MuiLink,
-} from '@mui/material';
-import { getDay } from '../../shared/utils/date';
-import { useAtomValue } from 'jotai';
-import { SelectedProjectAtom } from './states';
-import BannerProjectCard from '../home/v1/components/banner-project-card';
-import { ProjectLoadingSkeleton } from '../../shared/components/atoms/skeleton';
-import { HomePageProjectsAtom } from '../home/v1/states';
-import useProject from './hooks';
-import CommentsWrapper from '../../shared/components/organisms/comments-wrapper';
-import ProjectInteraction from './components/project-interaction';
-import ProjectContent from './components/project-content';
-import {
- defaultDarkThumbnail,
- defaultLightThumbnail,
-} from '../editor/constants';
-import { useA2ZTheme } from '../../shared/hooks/use-theme';
-import OpenInNewIcon from '@mui/icons-material/OpenInNew';
-import GitHubIcon from '@mui/icons-material/GitHub';
-import { getAllProjectsResponse } from '../../infra/rest/apis/project/typing';
-import { OutputBlockData } from '@editorjs/editorjs';
-import { CommentsWrapperAtom } from '../../shared/components/organisms/comments-wrapper/states';
-
-const Project = () => {
- const { project_id } = useParams();
- const { theme: a2zTheme } = useA2ZTheme();
- const selectedProject = useAtomValue(SelectedProjectAtom);
- const similarProjects = useAtomValue(HomePageProjectsAtom);
- const commentsWrapper = useAtomValue(CommentsWrapperAtom);
- const { fetchProject, loading } = useProject();
-
- useEffect(() => {
- fetchProject(project_id || '');
- }, [project_id, fetchProject]);
-
- if (loading || !selectedProject) {
- return ;
- }
-
- return (
- <>
- {commentsWrapper && }
-
-
- {/* Header Section */}
-
-
- {selectedProject.title}
-
-
-
- {selectedProject.live_url && (
- }
- >
- Live URL
-
- )}
-
- {selectedProject.repository_url && (
- }
- >
- GitHub
-
- )}
-
-
-
- {/* Banner Image */}
-
-
- {/* Author Info + Publish Date */}
-
-
-
-
- {selectedProject.user_id.personal_info.fullname}
-
@
-
- {selectedProject.user_id.personal_info.username}
-
-
-
-
-
- Published on {getDay(selectedProject.publishedAt)}
-
-
-
- {/* Project Interaction Section */}
-
-
- {/* Project Content */}
-
- {selectedProject.content_blocks &&
- selectedProject.content_blocks[0]?.blocks?.map(
- (block: OutputBlockData, i: number) => (
-
-
-
- )
- )}
-
-
-
-
- {/* Similar Projects Section */}
- {similarProjects && similarProjects.length > 0 && (
-
-
- Similar Projects
-
-
- {similarProjects.map(
- (similarProject: getAllProjectsResponse, i: number) => (
-
- )
- )}
-
- )}
-
- >
- );
-};
-
-export default Project;
diff --git a/client/src/modules/search/index.tsx b/client/src/modules/search/index.tsx
index 895f6bf4..3a9e847b 100644
--- a/client/src/modules/search/index.tsx
+++ b/client/src/modules/search/index.tsx
@@ -7,7 +7,7 @@ import { HomePageProjectsAtom } from '../home/v1/states';
import { Virtuoso } from 'react-virtuoso';
import { BannerSkeleton } from '../../shared/components/atoms/skeleton';
import NoDataMessageBox from '../../shared/components/atoms/no-data-msg';
-import useHome from '../home/v1/hooks';
+import useHomeV1 from '../home/v1/hooks';
import { SearchPageUsersAtom } from './states';
import UserCard from './components/user-card';
import { Box, CircularProgress, Stack } from '@mui/material';
@@ -18,7 +18,7 @@ import PeopleIcon from '@mui/icons-material/People';
const Search = () => {
const { query } = useParams();
const projects = useAtomValue(HomePageProjectsAtom);
- const { fetchProjectsByCategory } = useHome();
+ const { fetchProjectsByCategory } = useHomeV1();
const users = useAtomValue(SearchPageUsersAtom);
const { fetchUsers } = useSearch();
diff --git a/client/src/modules/settings/modules/index.tsx b/client/src/modules/settings/modules/index.tsx
new file mode 100644
index 00000000..05321729
--- /dev/null
+++ b/client/src/modules/settings/modules/index.tsx
@@ -0,0 +1,3 @@
+import { lazy } from 'react';
+
+export const ProfileLazyComponentV1 = lazy(() => import('./profile/v1'));
diff --git a/client/src/modules/settings/v1/hooks/get-settings.tsx b/client/src/modules/settings/routes/index.tsx
similarity index 55%
rename from client/src/modules/settings/v1/hooks/get-settings.tsx
rename to client/src/modules/settings/routes/index.tsx
index 6ccdb6a4..bec90c3e 100644
--- a/client/src/modules/settings/v1/hooks/get-settings.tsx
+++ b/client/src/modules/settings/routes/index.tsx
@@ -1,16 +1,14 @@
-/* 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 {
+ ROUTES_V1,
+ ROUTES_SETTINGS_V1,
+} from '../../app/routes/constants/routes';
+import { SettingTabType } from '../v1/typings';
import { Navigate, Route } from 'react-router-dom';
-import { ProtectedRoute } from '../../../app/routes/auth-routes/protected-route';
-import { lazy } from 'react';
+import { ProtectedRoute } from '../../app/routes/auth-routes/protected-route';
+import { ProfileLazyComponentV1 } from '../modules';
-export const ProfileLazyComponentV1 = lazy(
- () => import('../../modules/profile/v1')
-);
-
-export const getSettings = ({
+export const settingsRoutes = ({
isMobileOrTablet,
}: {
isMobileOrTablet: boolean;
@@ -19,7 +17,7 @@ export const getSettings = ({
{
id: 'profile',
icon: ,
- path: ROUTES_V1.SETTINGS_PROFILE,
+ path: ROUTES_SETTINGS_V1.PROFILE,
name: 'Your profile',
description: 'Edit your personal details',
},
@@ -27,8 +25,8 @@ export const getSettings = ({
const routes: React.ReactNode[] = [
}
@@ -40,7 +38,7 @@ export const getSettings = ({
path="*"
element={
}
@@ -49,7 +47,7 @@ export const getSettings = ({
];
return {
- settings: settings,
- routes: routes,
+ settings,
+ routes,
};
};
diff --git a/client/src/modules/settings/v1/hooks/index.ts b/client/src/modules/settings/v1/hooks/index.ts
index 459d583b..0b4f58c7 100644
--- a/client/src/modules/settings/v1/hooks/index.ts
+++ b/client/src/modules/settings/v1/hooks/index.ts
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
-import { getSettings } from './get-settings';
+import { settingsRoutes } from '../../routes';
import { ROUTES_V1 } from '../../../app/routes/constants/routes';
import { useDevice } from '../../../../shared/hooks/use-device';
@@ -13,7 +13,7 @@ const useSettingsV1 = () => {
setSearchTerm('');
};
- const { settings, routes } = getSettings({ isMobileOrTablet });
+ const { settings, routes } = settingsRoutes({ isMobileOrTablet });
const filteredSettings = useMemo(() => {
if (!searchTerm) return settings;
return settings.filter(setting =>
diff --git a/client/src/shared/components/atoms/typography/index.tsx b/client/src/shared/components/atoms/typography/index.tsx
index 9f1cb579..bd6f47b3 100644
--- a/client/src/shared/components/atoms/typography/index.tsx
+++ b/client/src/shared/components/atoms/typography/index.tsx
@@ -24,6 +24,12 @@ const A2ZTypography = ({
noWrap?: boolean;
props?: Record;
}) => {
+ // If dangerouslySetInnerHTML is provided, don't render text as children
+ const hasDangerousHTML =
+ props &&
+ 'dangerouslySetInnerHTML' in props &&
+ props.dangerouslySetInnerHTML;
+
return (
- {text}
+ {hasDangerousHTML ? null : text}
);
};
diff --git a/client/src/shared/components/molecules/searchbar/index.tsx b/client/src/shared/components/molecules/searchbar/index.tsx
index 390a4bfb..f0c38830 100644
--- a/client/src/shared/components/molecules/searchbar/index.tsx
+++ b/client/src/shared/components/molecules/searchbar/index.tsx
@@ -1,4 +1,11 @@
-import React, { useState } from 'react';
+import {
+ Ref,
+ KeyboardEvent,
+ ChangeEvent,
+ useState,
+ forwardRef,
+ useRef,
+} from 'react';
import {
Box,
IconButton,
@@ -22,152 +29,184 @@ interface SearchbarProps {
variant?: 'fullWidth' | 'iconButton' | 'auto';
sx?: SxProps;
autoFocus?: boolean;
+ onKeyDown?: (event: KeyboardEvent) => void;
+ inputRef?: Ref;
+ showClearButton?: 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 Searchbar = forwardRef(
+ (
+ {
+ customCSS = {},
+ placeholder = 'Search settings...',
+ showLoading = false,
+ onSearch,
+ searchTerm,
+ handleOnClearClick,
+ variant = 'auto',
+ sx = {},
+ autoFocus = true,
+ onKeyDown,
+ inputRef,
+ showClearButton = true,
+ },
+ ref
+ ) => {
+ const theme = useTheme();
+ const { isMobile } = useDevice();
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const internalInputRef = useRef(null);
- const handleInputChange = (value: string) => {
- onSearch(value);
- };
+ const handleInputChange = (value: string) => {
+ onSearch(value);
+ };
- const shouldShowIconButton =
- variant === 'iconButton' || (variant === 'auto' && isMobile);
+ const shouldShowIconButton =
+ variant === 'iconButton' || (variant === 'auto' && isMobile);
- const handleIconButtonClick = () => {
- setIsDrawerOpen(true);
- };
+ const handleIconButtonClick = () => {
+ setIsDrawerOpen(true);
+ };
- const handleDrawerClose = () => {
- setIsDrawerOpen(false);
- };
+ const handleDrawerClose = () => {
+ setIsDrawerOpen(false);
+ };
- const searchbarContent = (
-
-
+ const handleClearClick = () => {
+ handleOnClearClick();
+ // Focus the input after clearing
+ setTimeout(() => {
+ const refToUse = inputRef || ref || internalInputRef;
+ if (
+ refToUse &&
+ typeof refToUse === 'object' &&
+ 'current' in refToUse &&
+ refToUse.current
+ ) {
+ refToUse.current.focus();
}
+ }, 0);
+ };
+
+ const searchbarContent = (
+ ) =>
- handleInputChange(e.target.value),
- autoFocus: autoFocus && !shouldShowIconButton,
- },
- }}
- />
- {showLoading && (
-
-
-
- )}
- {searchTerm && (
-
-
-
- )}
-
- );
-
- if (shouldShowIconButton) {
- return (
- <>
-
+
+ }
sx={{
- color: 'text.primary',
- '&:hover': {
- bgcolor: 'action.hover',
+ 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,
},
}}
- aria-label="Open search"
- >
-
-
- ) =>
+ handleInputChange(e.target.value),
+ onKeyDown: onKeyDown,
+ autoFocus: autoFocus && !shouldShowIconButton,
},
}}
- >
- {searchbarContent}
-
- >
+ />
+ {showLoading && (
+
+
+
+ )}
+ {showClearButton && searchTerm && (
+
+
+
+ )}
+
);
+
+ if (shouldShowIconButton) {
+ return (
+ <>
+
+
+
+
+ {searchbarContent}
+
+ >
+ );
+ }
+
+ return searchbarContent;
}
+);
- return searchbarContent;
-};
+Searchbar.displayName = 'Searchbar';
export default Searchbar;
diff --git a/client/src/shared/components/organisms/comments-wrapper/components/comment-card.tsx b/client/src/shared/components/organisms/comments-wrapper/components/comment-card.tsx
index 05f4f81f..833ca531 100644
--- a/client/src/shared/components/organisms/comments-wrapper/components/comment-card.tsx
+++ b/client/src/shared/components/organisms/comments-wrapper/components/comment-card.tsx
@@ -17,7 +17,7 @@ import {
TotalParentCommentsLoadedAtom,
} from '../states';
import { UserAtom } from '../../../../../infra/states/user';
-import { SelectedProjectAtom } from '../../../../../modules/project/states';
+import { SelectedProjectAtom } from '../../../../../modules/home/modules/project/v1/states';
import { useNotifications } from '../../../../hooks/use-notification';
import {
deleteComment,
diff --git a/client/src/shared/components/organisms/comments-wrapper/components/comment-field.tsx b/client/src/shared/components/organisms/comments-wrapper/components/comment-field.tsx
index 262cc42b..dbc4c94b 100644
--- a/client/src/shared/components/organisms/comments-wrapper/components/comment-field.tsx
+++ b/client/src/shared/components/organisms/comments-wrapper/components/comment-field.tsx
@@ -9,7 +9,7 @@ import {
import { useNotifications } from '../../../../hooks/use-notification';
import { UserAtom } from '../../../../../infra/states/user';
import { useAuth } from '../../../../hooks/use-auth';
-import { SelectedProjectAtom } from '../../../../../modules/project/states';
+import { SelectedProjectAtom } from '../../../../../modules/home/modules/project/v1/states';
import { addComment } from '../../../../../infra/rest/apis/comment';
const CommentField = ({
diff --git a/client/src/shared/components/organisms/comments-wrapper/hooks/index.ts b/client/src/shared/components/organisms/comments-wrapper/hooks/index.ts
index 5caf07bd..48d28b06 100644
--- a/client/src/shared/components/organisms/comments-wrapper/hooks/index.ts
+++ b/client/src/shared/components/organisms/comments-wrapper/hooks/index.ts
@@ -1,49 +1,80 @@
-import { useAtom, useAtomValue } from 'jotai';
+import { useCallback, useRef, useEffect } from 'react';
+import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { getComments } from '../../../../../infra/rest/apis/comment';
import { GetCommentsResponse } from '../../../../../infra/rest/apis/comment/typing';
import { AllCommentsAtom, TotalParentCommentsLoadedAtom } from '../states';
-import { SelectedProjectAtom } from '../../../../../modules/project/states';
+import { SelectedProjectAtom } from '../../../../../modules/home/modules/project/v1/states';
const useCommentsWrapper = () => {
const [totalParentCommentsLoaded, setTotalParentCommentsLoaded] = useAtom(
TotalParentCommentsLoadedAtom
);
- const [allComments, setAllComments] = useAtom(AllCommentsAtom);
+ const setAllComments = useSetAtom(AllCommentsAtom);
const selectedProject = useAtomValue(SelectedProjectAtom);
+ const currentProjectIdRef = useRef(null);
- const fetchComments = async ({ project_id }: { project_id: string }) => {
- try {
- const response = await getComments({
- project_id,
- skip: totalParentCommentsLoaded,
- });
- if (response.data) {
- const transformed = response.data.map(comment => ({
- ...comment,
- children_level: 0,
- })) as (GetCommentsResponse & { children_level: number })[];
- setTotalParentCommentsLoaded(transformed.length);
-
- if (transformed.length > 0) {
- if (!allComments) {
- setAllComments(transformed);
+ // Reset comments when project changes
+ useEffect(() => {
+ if (
+ selectedProject?._id &&
+ selectedProject._id !== currentProjectIdRef.current
+ ) {
+ currentProjectIdRef.current = selectedProject._id;
+ setAllComments(null);
+ setTotalParentCommentsLoaded(0);
+ }
+ }, [selectedProject?._id, setAllComments, setTotalParentCommentsLoaded]);
+
+ const fetchComments = useCallback(
+ async ({
+ project_id,
+ reset = false,
+ }: {
+ project_id: string;
+ reset?: boolean;
+ }) => {
+ if (!project_id) return;
+
+ try {
+ const skip = reset ? 0 : totalParentCommentsLoaded;
+ const response = await getComments({
+ project_id,
+ skip,
+ });
+ if (response.data) {
+ const transformed = response.data.map(comment => ({
+ ...comment,
+ children_level: 0,
+ })) as (GetCommentsResponse & { children_level: number })[];
+
+ if (reset) {
+ setTotalParentCommentsLoaded(transformed.length);
+ setAllComments(transformed.length > 0 ? transformed : null);
} else {
- setAllComments([...allComments, ...transformed]);
+ setTotalParentCommentsLoaded(prev => prev + transformed.length);
+ if (transformed.length > 0) {
+ setAllComments(prev => {
+ if (!prev) return transformed;
+ return [...prev, ...transformed];
+ });
+ }
}
}
+ } catch (err) {
+ console.error(err);
+ setAllComments(null);
}
- } catch (err) {
- console.error(err);
- setAllComments(null);
- }
- };
+ },
+ [totalParentCommentsLoaded, setTotalParentCommentsLoaded, setAllComments]
+ );
- const loadMoreComments = async () => {
+ const loadMoreComments = useCallback(async () => {
if (!selectedProject?._id) return;
await fetchComments({
project_id: selectedProject._id,
+ reset: false,
});
- };
+ }, [selectedProject?._id, fetchComments]);
return {
fetchComments,
diff --git a/client/src/shared/components/organisms/comments-wrapper/index.tsx b/client/src/shared/components/organisms/comments-wrapper/index.tsx
index 99bf33d0..59d6067a 100644
--- a/client/src/shared/components/organisms/comments-wrapper/index.tsx
+++ b/client/src/shared/components/organisms/comments-wrapper/index.tsx
@@ -10,7 +10,7 @@ import {
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import NoDataMessage from '../../atoms/no-data-msg';
-import { SelectedProjectAtom } from '../../../../modules/project/states';
+import { SelectedProjectAtom } from '../../../../modules/home/modules/project/v1/states';
import CommentField from './components/comment-field';
import CommentCard from './components/comment-card';
import {
diff --git a/client/src/shared/components/organisms/navbar/components/render-menu.tsx b/client/src/shared/components/organisms/navbar/components/render-menu.tsx
deleted file mode 100644
index 648e8f69..00000000
--- a/client/src/shared/components/organisms/navbar/components/render-menu.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { FC } from 'react';
-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;
- isMenuOpen: boolean;
- handleMenuClose: () => void;
-}
-
-const RenderMenu: FC = ({
- anchorEl,
- 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 (
-
- );
-};
-
-export default RenderMenu;
diff --git a/client/src/shared/components/organisms/navbar/components/render-mobile-menu.tsx b/client/src/shared/components/organisms/navbar/components/render-mobile-menu.tsx
index c39c905e..fa7ff608 100644
--- a/client/src/shared/components/organisms/navbar/components/render-mobile-menu.tsx
+++ b/client/src/shared/components/organisms/navbar/components/render-mobile-menu.tsx
@@ -1,39 +1,25 @@
import { Menu, MenuItem, Badge } from '@mui/material';
import { FC } from 'react';
-import LightModeIcon from '@mui/icons-material/LightMode';
-import DarkModeIcon from '@mui/icons-material/DarkMode';
import CreateIcon from '@mui/icons-material/Create';
-import LoginIcon from '@mui/icons-material/Login';
-import { menuId, mobileMenuId } from '../constants';
+import { mobileMenuId } from '../constants';
import A2ZIconButton from '../../../atoms/icon-button';
import A2ZTypography from '../../../atoms/typography';
-import AccountCircle from '@mui/icons-material/AccountCircle';
import MailIcon from '@mui/icons-material/Mail';
-import NotificationsIcon from '@mui/icons-material/Notifications';
-import { THEME } from '../../../../states/theme';
-import { useA2ZTheme } from '../../../../hooks/use-theme';
import { useNavigate } from 'react-router-dom';
-import { useAuth } from '../../../../hooks/use-auth';
interface RenderMobileMenuProps {
mobileMoreAnchorEl: HTMLElement | null;
isMobileMenuOpen: boolean;
- handleProfileMenuOpen: (event: React.MouseEvent) => void;
handleMobileMenuClose: () => void;
setShowSubscribeModal: (show: boolean) => void;
- notificationCount: number;
}
const RenderMobileMenu: FC = ({
mobileMoreAnchorEl,
isMobileMenuOpen,
- handleProfileMenuOpen,
handleMobileMenuClose,
setShowSubscribeModal,
- notificationCount,
}) => {
- const { isAuthenticated } = useAuth();
- const { theme, setTheme } = useA2ZTheme();
const navigate = useNavigate();
return (
);
};
diff --git a/client/src/shared/components/organisms/navbar/hooks/index.ts b/client/src/shared/components/organisms/navbar/hooks/index.ts
index 4ae4ea11..819c1ffc 100644
--- a/client/src/shared/components/organisms/navbar/hooks/index.ts
+++ b/client/src/shared/components/organisms/navbar/hooks/index.ts
@@ -1,53 +1,43 @@
-import { useRef, useState } from 'react';
+import { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDevice } from '../../../../hooks/use-device';
import { useNotifications } from '../../../../hooks/use-notification';
import { emailRegex } from '../../../../utils/regex';
import { subscribeUser } from '../../../../../infra/rest/apis/subscriber';
-import { allNotificationCounts } from '../../../../../infra/rest/apis/notification';
-import { NOTIFICATION_FILTER_TYPE } from '../../../../../infra/rest/typings';
export const useNavbar = () => {
const navigate = useNavigate();
const { isDesktop } = useDevice();
+ const searchInputRef = useRef(null);
- const [anchorEl, setAnchorEl] = useState(null);
const [mobileMoreAnchorEl, setMobileMoreAnchorEl] =
useState(null);
- const [searchBoxVisibility, setSearchBoxVisibility] =
- useState(false);
- const [notificationCount, setNotificationCount] = useState(0);
+ const [searchTerm, setSearchTerm] = useState('');
- const searchRef = useRef(null);
- const isMenuOpen = Boolean(anchorEl);
const isMobileMenuOpen = Boolean(mobileMoreAnchorEl);
- const handleProfileMenuOpen = (event: React.MouseEvent) => {
- setAnchorEl(event.currentTarget);
- };
-
const handleMobileMenuClose = () => {
setMobileMoreAnchorEl(null);
};
- const handleMenuClose = () => {
- setAnchorEl(null);
- handleMobileMenuClose();
- };
-
const handleMobileMenuOpen = (event: React.MouseEvent) => {
setMobileMoreAnchorEl(event.currentTarget);
};
- const handleSearch = (event: React.KeyboardEvent) => {
- if (event.key === 'Enter' || event.keyCode === 13) {
- const query = event.currentTarget.value;
- if (!query.trim().length) {
- navigate('/');
- return;
- }
- navigate(`/search/${encodeURIComponent(query)}`);
+ const handleSearchChange = (value: string) => {
+ setSearchTerm(value);
+ };
+
+ const handleSearchSubmit = () => {
+ if (!searchTerm.trim().length) {
+ navigate('/');
+ return;
}
+ navigate(`/search/${encodeURIComponent(searchTerm)}`);
+ };
+
+ const handleClearSearch = () => {
+ setSearchTerm('');
};
const triggerSearchByKeyboard = (e: KeyboardEvent) => {
@@ -55,54 +45,34 @@ export const useNavbar = () => {
e.preventDefault();
e.stopPropagation();
- if (isDesktop) {
+ if (isDesktop && searchInputRef.current) {
setTimeout(() => {
- if (searchRef.current) {
- searchRef.current.focus();
- searchRef.current.select();
+ if (searchInputRef.current) {
+ searchInputRef.current.focus();
+ searchInputRef.current.select();
}
}, 10);
- } else {
- setSearchBoxVisibility(true);
- setTimeout(() => {
- if (searchRef.current) {
- searchRef.current.focus();
- }
- }, 100);
}
}
};
- const fetchNotificationCount = async () => {
- try {
- const response = await allNotificationCounts({
- filter: NOTIFICATION_FILTER_TYPE.ALL,
- });
- if (response.status === 'success') {
- setNotificationCount(response?.data?.totalDocs || 0);
- }
- } catch (error) {
- console.error('Error fetching notification count:', error);
- }
- };
+ useEffect(() => {
+ document.addEventListener('keydown', triggerSearchByKeyboard, true);
+ return () => {
+ document.removeEventListener('keydown', triggerSearchByKeyboard, true);
+ };
+ }, [isDesktop]);
return {
- anchorEl,
mobileMoreAnchorEl,
- isMenuOpen,
isMobileMenuOpen,
- handleProfileMenuOpen,
handleMobileMenuClose,
- handleMenuClose,
handleMobileMenuOpen,
- handleSearch,
- searchRef,
- searchBoxVisibility,
- setSearchBoxVisibility,
- triggerSearchByKeyboard,
- notificationCount,
- setNotificationCount,
- fetchNotificationCount,
+ searchTerm,
+ handleSearchChange,
+ handleSearchSubmit,
+ handleClearSearch,
+ searchInputRef,
};
};
diff --git a/client/src/shared/components/organisms/navbar/index.tsx b/client/src/shared/components/organisms/navbar/index.tsx
index 349e8040..ff63ab6c 100644
--- a/client/src/shared/components/organisms/navbar/index.tsx
+++ b/client/src/shared/components/organisms/navbar/index.tsx
@@ -1,50 +1,27 @@
-import { AppBar, Box, Toolbar, Badge } from '@mui/material';
-import SearchIcon from '@mui/icons-material/Search';
-import AccountCircle from '@mui/icons-material/AccountCircle';
+import { Box, Badge, useTheme } from '@mui/material';
import MailIcon from '@mui/icons-material/Mail';
-import NotificationsIcon from '@mui/icons-material/Notifications';
-import LightModeIcon from '@mui/icons-material/LightMode';
-import DarkModeIcon from '@mui/icons-material/DarkMode';
-import LoginIcon from '@mui/icons-material/Login';
import CreateIcon from '@mui/icons-material/Create';
import MoreIcon from '@mui/icons-material/MoreVert';
import A2ZIconButton from '../../atoms/icon-button';
-import A2ZTypography from '../../atoms/typography';
import { useNavbar, useSubscribe } from './hooks';
-import { menuId, mobileMenuId } from './constants';
+import { mobileMenuId } from './constants';
import RenderMobileMenu from './components/render-mobile-menu';
-import RenderMenu from './components/render-menu';
import SubscribeModal from './components/subscribe';
-import InputBox from '../../atoms/input-box';
-import { useEffect } from 'react';
-import { useDevice } from '../../../hooks/use-device';
-import { THEME } from '../../../states/theme';
-import { useA2ZTheme } from '../../../hooks/use-theme';
-import { Outlet, useNavigate } from 'react-router-dom';
-import { useAuth } from '../../../hooks/use-auth';
-import { useThrottledFetch } from '../../../hooks/use-throttle-fetch';
+import Searchbar from '../../molecules/searchbar';
+import { HEADER_HEIGHT } from '../header/constants';
const Navbar = () => {
- const { isAuthenticated } = useAuth();
- const { isDesktop } = useDevice();
- const { theme, setTheme } = useA2ZTheme();
- const navigate = useNavigate();
+ const theme = useTheme();
const {
- anchorEl,
mobileMoreAnchorEl,
- isMenuOpen,
isMobileMenuOpen,
- handleProfileMenuOpen,
handleMobileMenuOpen,
- handleMenuClose,
handleMobileMenuClose,
- handleSearch,
- searchRef,
- searchBoxVisibility,
- setSearchBoxVisibility,
- triggerSearchByKeyboard,
- notificationCount,
- fetchNotificationCount,
+ searchTerm,
+ handleSearchChange,
+ handleSearchSubmit,
+ handleClearSearch,
+ searchInputRef,
} = useNavbar();
const {
@@ -54,153 +31,80 @@ const Navbar = () => {
handleSubscribe,
} = useSubscribe();
- useEffect(() => {
- document.addEventListener('keydown', triggerSearchByKeyboard, true);
- return () => {
- document.removeEventListener('keydown', triggerSearchByKeyboard, true);
- };
- }, [isDesktop, triggerSearchByKeyboard]);
-
- useThrottledFetch({
- isAuthenticated: isAuthenticated(),
- fetch: fetchNotificationCount,
- storageKey: 'a2z_notifications_last_fetch',
- });
-
return (
- <>
-
-
-
-
-
-
-
-
- }
- sx={{
- padding: { xs: 0, sm: '0 1.25rem' },
- }}
- slotProps={{
- htmlInput: {
- onFocus: () => setSearchBoxVisibility(true),
- onBlur: () => setSearchBoxVisibility(false),
- onKeyDown: handleSearch,
- sx: {
- padding: '10px 0',
- },
- },
- }}
- />
-
-
-
-
-
-
- setTheme(theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT)
- }
- >
- {theme === THEME.DARK ? : }
-
-
-
-
-
-
-
-
+
+
+ {
+ if (e.key === 'Enter' || e.keyCode === 13) {
+ handleSearchSubmit();
+ }
+ }}
+ />
- {isAuthenticated() ? (
- <>
-
-
-
-
-
-
-
-
- >
- ) : (
- <>
-
- setShowSubscribeModal(true)}>
-
-
-
-
- navigate('/login')}>
-
-
-
- >
- )}
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+ setShowSubscribeModal(true)}>
+
+
+
+
-
+
+
+
+
+
-
- >
+
+
+
+
);
};
diff --git a/client/src/shared/hooks/use-auth.ts b/client/src/shared/hooks/use-auth.ts
index ac5037c5..58710747 100644
--- a/client/src/shared/hooks/use-auth.ts
+++ b/client/src/shared/hooks/use-auth.ts
@@ -1,5 +1,5 @@
-import { useSetAtom, useAtom } from 'jotai';
-import { useEffect, useState, useCallback } from 'react';
+import { useSetAtom, useAtom, useAtomValue } from 'jotai';
+import { useEffect, useState, useCallback, useRef } from 'react';
import { UserAtom } from '../../infra/states/user';
import { TokenAtom } from '../../infra/states/auth';
import { refreshToken } from '../../infra/rest/apis/auth';
@@ -13,8 +13,10 @@ import { TOKEN_CONFIG } from '../../config/env';
export const useAuth = () => {
const [token, setToken] = useAtom(TokenAtom);
+ const user = useAtomValue(UserAtom);
const setUser = useSetAtom(UserAtom);
const [initialized, setInitialized] = useState(false);
+ const hasFetchedUserRef = useRef(false);
// Initialize tokens and fetch user data from server on app start
useEffect(() => {
@@ -23,15 +25,20 @@ export const useAuth = () => {
if (accessToken) {
setToken(accessToken);
- try {
- const response = await getCurrentUser();
- if (response.status === 'success' && response.data) {
- setUser(response.data);
+ // Only fetch user if we don't already have user data and haven't fetched yet
+ if (!user && !hasFetchedUserRef.current) {
+ hasFetchedUserRef.current = true;
+ 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);
+ hasFetchedUserRef.current = false; // Allow retry on next mount if needed
}
- } 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);
}
}