diff --git a/client/src/App.tsx b/client/src/App.tsx index 1748c6e6..2f3bd185 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,59 +1,40 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import MuiThemeProvider from './shared/components/molecules/theme'; -import A2ZNotifications from './shared/components/molecules/notification'; -import Navbar from './shared/components/organisms/navbar'; +import { memo, useEffect, useState, useMemo } from 'react'; +import { Provider } from 'jotai'; import { setupTokenRefresh } from './shared/utils/api-interceptor'; -import { useEffect } from 'react'; -import Home from './modules/home'; -import UserAuthForm from './modules/user-auth-form'; -import Editor from './modules/editor'; -import PageNotFound from './modules/404'; -import Search from './modules/search'; -import Profile from './modules/profile'; -import Project from './modules/project'; -import Sidebar from './shared/components/organisms/sidebar'; -import ChangePassword from './modules/change-password'; -import ManageProjects from './modules/manage-projects'; -import EditProfile from './modules/edit-profile'; -import Notifications from './modules/notification'; +import { AppUnProtectedRoutes } from './modules/app/routes'; +import { AppProtectedRoutes } from './modules/app/routes/auth-routes'; +import useScrollbar from './shared/components/atoms/scrollbar'; +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]); -function App() { useEffect(() => { setupTokenRefresh(); }, []); - return ( - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - }> - } /> - } /> - - }> - } /> - } /> - - - - } /> - } /> + useEffect(() => { + setCacheKey(Date.now().toString()); + }, [isAuth]); - } /> - + if (!isAuth) { + return ( + <> + + + + ); + } - - - + return ( + + + + ); -} +}); export default App; diff --git a/client/src/config/env.ts b/client/src/config/env.ts index ded48194..f3fe896b 100644 --- a/client/src/config/env.ts +++ b/client/src/config/env.ts @@ -1,3 +1,10 @@ +import packageDetails from '../../package.json'; + +export const appVersion = packageDetails.version; + +export const ORGANISATION_NAME = + import.meta.env.VITE_ORGANISATION_NAME || 'Code A2Z'; + // Load environment variables from .env file export const VITE_SERVER_DOMAIN = import.meta.env.VITE_SERVER_DOMAIN || 'https://code-a2z-server.vercel.app'; // https://code-a2z.onrender.com for production diff --git a/client/src/main.tsx b/client/src/main.tsx index b31fb6bc..c18c1c2f 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,9 +1,26 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; +import createCache from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; +import MuiThemeProvider from './shared/components/molecules/theme'; +import { BrowserRouter } from 'react-router-dom'; +import A2ZNotifications from './shared/components/molecules/notification'; + +const cache = createCache({ + key: 'myapp', + nonce: 'code-a2z-css', // 🔑 must match the CSP nonce +}); createRoot(document.getElementById('root')!).render( - + + + + + + + + ); diff --git a/client/src/modules/app/components/index.tsx b/client/src/modules/app/components/index.tsx new file mode 100644 index 00000000..f633be02 --- /dev/null +++ b/client/src/modules/app/components/index.tsx @@ -0,0 +1,63 @@ +/** @jsxImportSource @emotion/react */ +import { css } from '@emotion/react'; +import { lazy } from 'react'; +import Header from '../../../shared/components/organisms/header'; +import { HEADER_HEIGHT } from '../../../shared/components/organisms/header/constants'; +import Sidebar from '../../../shared/components/organisms/sidebar'; +import { SIDEBAR_WIDTH } from '../../../shared/components/organisms/sidebar/constants'; +import { Routes } from 'react-router-dom'; +import getRoutesV1 from '../routes/auth-routes/v1'; + +export const LoginLazyComponent = lazy(() => import('../../user-auth-form/v1')); +export const HomePageLazyComponent = lazy(() => import('../../home/v1')); +export const SettingsPageLazyComponent = lazy( + () => import('../../settings/v1') +); + +export const AppLayout = () => { + return ( + <> +
+
+
+ +
+
+ {getRoutesV1()} +
+
+
+
+ + ); +}; diff --git a/client/src/modules/app/routes/auth-routes/index.tsx b/client/src/modules/app/routes/auth-routes/index.tsx new file mode 100644 index 00000000..7110de52 --- /dev/null +++ b/client/src/modules/app/routes/auth-routes/index.tsx @@ -0,0 +1,10 @@ +import { Route, Routes } from 'react-router-dom'; +import { AppLayout } from '../../components'; + +export function AppProtectedRoutes() { + return ( + + } /> + + ); +} diff --git a/client/src/modules/app/routes/auth-routes/v1/index.tsx b/client/src/modules/app/routes/auth-routes/v1/index.tsx new file mode 100644 index 00000000..bab73c9a --- /dev/null +++ b/client/src/modules/app/routes/auth-routes/v1/index.tsx @@ -0,0 +1,44 @@ +import { Suspense } from 'react'; +import { Navigate, Route } from 'react-router-dom'; +import { ROUTES_V1 } from '../../constants/routes'; +import { LOADING } from '../../constants'; +import Loader from '../../../../../shared/components/molecules/loader'; +import { + HomePageLazyComponent, + SettingsPageLazyComponent, +} from '../../../components'; + +export default function getRoutesV1() { + const routes = [ + }> + + + } + />, + }> + + + } + />, + ]; + + if (routes.length) { + routes.push( + } + /> + ); + } + + return routes; +} diff --git a/client/src/modules/app/routes/constants/index.ts b/client/src/modules/app/routes/constants/index.ts new file mode 100644 index 00000000..73cafec0 --- /dev/null +++ b/client/src/modules/app/routes/constants/index.ts @@ -0,0 +1 @@ +export const LOADING = 'Loading...'; diff --git a/client/src/modules/app/routes/constants/routes.ts b/client/src/modules/app/routes/constants/routes.ts new file mode 100644 index 00000000..1f68e7f6 --- /dev/null +++ b/client/src/modules/app/routes/constants/routes.ts @@ -0,0 +1,9 @@ +export enum ROUTES_V1 { + HOME = '/v1/home', + SETTINGS = '/v1/settings', +} + +export enum ROUTES_PAGE_V1 { + HOME = 'home', + SETTINGS = 'settings', +} diff --git a/client/src/modules/app/routes/index.tsx b/client/src/modules/app/routes/index.tsx new file mode 100644 index 00000000..ee61defa --- /dev/null +++ b/client/src/modules/app/routes/index.tsx @@ -0,0 +1,20 @@ +import { Suspense } from 'react'; +import { Route, Routes } from 'react-router-dom'; +import Loader from '../../../shared/components/molecules/loader'; +import { LOADING } from './constants'; +import { LoginLazyComponent } from '../components'; + +export function AppUnProtectedRoutes() { + return ( + + }> + + + } + /> + + ); +} diff --git a/client/src/modules/home/components/banner-project-card.tsx b/client/src/modules/home/v1/components/banner-project-card.tsx similarity index 94% rename from client/src/modules/home/components/banner-project-card.tsx rename to client/src/modules/home/v1/components/banner-project-card.tsx index f5ba59b3..89fef768 100644 --- a/client/src/modules/home/components/banner-project-card.tsx +++ b/client/src/modules/home/v1/components/banner-project-card.tsx @@ -2,13 +2,13 @@ import { Link } from 'react-router-dom'; import { Avatar, Box, Chip, Stack, Typography, useTheme } from '@mui/material'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; -import { useA2ZTheme } from '../../../shared/hooks/use-theme'; -import { getDay } from '../../../shared/utils/date'; +import { useA2ZTheme } from '../../../../shared/hooks/use-theme'; +import { getDay } from '../../../../shared/utils/date'; import { defaultDarkThumbnail, defaultLightThumbnail, -} from '../../editor/constants'; -import { getAllProjectsResponse } from '../../../infra/rest/apis/project/typing'; +} from '../../../editor/constants'; +import { getAllProjectsResponse } from '../../../../infra/rest/apis/project/typing'; const BannerProjectCard = ({ project, diff --git a/client/src/modules/home/components/category-button.tsx b/client/src/modules/home/v1/components/category-button.tsx similarity index 100% rename from client/src/modules/home/components/category-button.tsx rename to client/src/modules/home/v1/components/category-button.tsx diff --git a/client/src/modules/home/components/no-banner-project.tsx b/client/src/modules/home/v1/components/no-banner-project.tsx similarity index 93% rename from client/src/modules/home/components/no-banner-project.tsx rename to client/src/modules/home/v1/components/no-banner-project.tsx index 9320d94c..14b10161 100644 --- a/client/src/modules/home/components/no-banner-project.tsx +++ b/client/src/modules/home/v1/components/no-banner-project.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom'; import { Avatar, Box, Typography, Stack, useTheme } from '@mui/material'; -import { getTrendingProjectsResponse } from '../../../infra/rest/apis/project/typing'; -import { getDay } from '../../../shared/utils/date'; +import { getTrendingProjectsResponse } from '../../../../infra/rest/apis/project/typing'; +import { getDay } from '../../../../shared/utils/date'; interface NoBannerProjectCardProps { project: getTrendingProjectsResponse; diff --git a/client/src/modules/home/constants/index.ts b/client/src/modules/home/v1/constants/index.ts similarity index 100% rename from client/src/modules/home/constants/index.ts rename to client/src/modules/home/v1/constants/index.ts diff --git a/client/src/modules/home/hooks/index.ts b/client/src/modules/home/v1/hooks/index.ts similarity index 96% rename from client/src/modules/home/hooks/index.ts rename to client/src/modules/home/v1/hooks/index.ts index b4172c29..0446de65 100644 --- a/client/src/modules/home/hooks/index.ts +++ b/client/src/modules/home/v1/hooks/index.ts @@ -4,7 +4,7 @@ import { getAllProjects, getTrendingProjects, searchProjects, -} from '../../../infra/rest/apis/project'; +} from '../../../../infra/rest/apis/project'; const useHome = () => { const setProjects = useSetAtom(HomePageProjectsAtom); diff --git a/client/src/modules/home/index.tsx b/client/src/modules/home/v1/index.tsx similarity index 94% rename from client/src/modules/home/index.tsx rename to client/src/modules/home/v1/index.tsx index 198a02e0..1fca85c8 100644 --- a/client/src/modules/home/index.tsx +++ b/client/src/modules/home/v1/index.tsx @@ -1,9 +1,9 @@ import { Box, Stack } from '@mui/material'; -import A2ZTypography from '../../shared/components/atoms/typography'; +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 InPageNavigation from '../../../shared/components/molecules/page-navigation'; import NoBannerProjectCard from './components/no-banner-project'; import { useAtom, useAtomValue } from 'jotai'; import { @@ -12,11 +12,11 @@ import { HomePageTrendingProjectsAtom, } from './states'; import BannerProjectCard from './components/banner-project-card'; -import NoDataMessageBox from '../../shared/components/atoms/no-data-msg'; +import NoDataMessageBox from '../../../shared/components/atoms/no-data-msg'; import { BannerSkeleton, NoBannerSkeleton, -} from '../../shared/components/atoms/skeleton'; +} from '../../../shared/components/atoms/skeleton'; import { useEffect } from 'react'; import useHome from './hooks'; import { Virtuoso } from 'react-virtuoso'; diff --git a/client/src/modules/home/states/index.ts b/client/src/modules/home/v1/states/index.ts similarity index 85% rename from client/src/modules/home/states/index.ts rename to client/src/modules/home/v1/states/index.ts index cabc8658..847c6d2e 100644 --- a/client/src/modules/home/states/index.ts +++ b/client/src/modules/home/v1/states/index.ts @@ -2,7 +2,7 @@ import { atom } from 'jotai'; import { getAllProjectsResponse, getTrendingProjectsResponse, -} from '../../../infra/rest/apis/project/typing'; +} from '../../../../infra/rest/apis/project/typing'; export const HomePageStateAtom = atom('home'); diff --git a/client/src/modules/profile/hooks/index.ts b/client/src/modules/profile/hooks/index.ts index 46f724ce..d4909c71 100644 --- a/client/src/modules/profile/hooks/index.ts +++ b/client/src/modules/profile/hooks/index.ts @@ -3,7 +3,7 @@ import { userProfile } from '../../../infra/rest/apis/user'; import { useParams } from 'react-router-dom'; import { useSetAtom } from 'jotai'; import { ProfileAtom } from '../states'; -import useHome from '../../home/hooks'; +import useHome from '../../home/v1/hooks'; const useProfile = () => { const { username } = useParams(); diff --git a/client/src/modules/profile/index.tsx b/client/src/modules/profile/index.tsx index 13b3775a..23e0a348 100644 --- a/client/src/modules/profile/index.tsx +++ b/client/src/modules/profile/index.tsx @@ -3,14 +3,14 @@ import { useParams } from 'react-router-dom'; import InPageNavigation from '../../shared/components/molecules/page-navigation'; 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 { HomePageProjectsAtom } from '../home/v1/states'; +import BannerProjectCard from '../home/v1/components/banner-project-card'; import { ProfileAtom } from './states'; 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/hooks'; +import useHome from '../home/v1/hooks'; import AboutUser from './components/about-user'; import A2ZTypography from '../../shared/components/atoms/typography'; import Button from '../../shared/components/atoms/button'; diff --git a/client/src/modules/project/hooks/index.ts b/client/src/modules/project/hooks/index.ts index ecf4e614..62730f9b 100644 --- a/client/src/modules/project/hooks/index.ts +++ b/client/src/modules/project/hooks/index.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'; import { getProjectById } from '../../../infra/rest/apis/project'; import { SelectedProjectAtom } from '../states'; import { useSetAtom } from 'jotai'; -import useHome from '../../home/hooks'; +import useHome from '../../home/v1/hooks'; import useCommentsWrapper from '../../../shared/components/organisms/comments-wrapper/hooks'; const useProject = () => { diff --git a/client/src/modules/project/index.tsx b/client/src/modules/project/index.tsx index fa2b5665..ea5577b3 100644 --- a/client/src/modules/project/index.tsx +++ b/client/src/modules/project/index.tsx @@ -10,9 +10,9 @@ import { import { getDay } from '../../shared/utils/date'; import { useAtomValue } from 'jotai'; import { SelectedProjectAtom } from './states'; -import BannerProjectCard from '../home/components/banner-project-card'; +import BannerProjectCard from '../home/v1/components/banner-project-card'; import { ProjectLoadingSkeleton } from '../../shared/components/atoms/skeleton'; -import { HomePageProjectsAtom } from '../home/states'; +import { HomePageProjectsAtom } from '../home/v1/states'; import useProject from './hooks'; import CommentsWrapper from '../../shared/components/organisms/comments-wrapper'; import ProjectInteraction from './components/project-interaction'; diff --git a/client/src/modules/search/index.tsx b/client/src/modules/search/index.tsx index 0bcc8e8a..895f6bf4 100644 --- a/client/src/modules/search/index.tsx +++ b/client/src/modules/search/index.tsx @@ -2,12 +2,12 @@ import { Navigate, useParams } from 'react-router-dom'; import InPageNavigation from '../../shared/components/molecules/page-navigation'; import { useEffect } from 'react'; import { useAtomValue } from 'jotai'; -import BannerProjectCard from '../home/components/banner-project-card'; -import { HomePageProjectsAtom } from '../home/states'; +import BannerProjectCard from '../home/v1/components/banner-project-card'; +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/hooks'; +import useHome from '../home/v1/hooks'; import { SearchPageUsersAtom } from './states'; import UserCard from './components/user-card'; import { Box, CircularProgress, Stack } from '@mui/material'; diff --git a/client/src/modules/settings/v1/index.tsx b/client/src/modules/settings/v1/index.tsx new file mode 100644 index 00000000..44523f5e --- /dev/null +++ b/client/src/modules/settings/v1/index.tsx @@ -0,0 +1,9 @@ +const Settings = () => { + return ( +
+

Settings

+
+ ); +}; + +export default Settings; diff --git a/client/src/modules/user-auth-form/hooks/index.ts b/client/src/modules/user-auth-form/v1/hooks/index.ts similarity index 85% rename from client/src/modules/user-auth-form/hooks/index.ts rename to client/src/modules/user-auth-form/v1/hooks/index.ts index fd98088b..71b51d78 100644 --- a/client/src/modules/user-auth-form/hooks/index.ts +++ b/client/src/modules/user-auth-form/v1/hooks/index.ts @@ -1,13 +1,13 @@ import { FormEvent, useState } from 'react'; -import { emailRegex, passwordRegex } from '../../../shared/utils/regex'; -import { useNotifications } from '../../../shared/hooks/use-notification'; -import { login, signUp } from '../../../infra/rest/apis/auth'; +import { emailRegex, passwordRegex } from '../../../../shared/utils/regex'; +import { useNotifications } from '../../../../shared/hooks/use-notification'; +import { login, signUp } from '../../../../infra/rest/apis/auth'; import { useSetAtom } from 'jotai'; -import { UserAtom } from '../../../infra/states/user'; +import { UserAtom } from '../../../../infra/states/user'; import { useSetAtom as useSetAtomGeneric } from 'jotai'; -import { TokenAtom } from '../../../infra/states/auth'; -import { setAccessToken } from '../../../shared/utils/local'; -import { ErrorResponse } from '../../../infra/rest/typings'; +import { TokenAtom } from '../../../../infra/states/auth'; +import { setAccessToken } from '../../../../shared/utils/local'; +import { ErrorResponse } from '../../../../infra/rest/typings'; import { useNavigate } from 'react-router-dom'; export const useUserAuthForm = ({ type }: { type: string }) => { diff --git a/client/src/modules/user-auth-form/index.tsx b/client/src/modules/user-auth-form/v1/index.tsx similarity index 63% rename from client/src/modules/user-auth-form/index.tsx rename to client/src/modules/user-auth-form/v1/index.tsx index 83d6e384..6718d104 100644 --- a/client/src/modules/user-auth-form/index.tsx +++ b/client/src/modules/user-auth-form/v1/index.tsx @@ -1,14 +1,14 @@ -import { Link, Navigate } from 'react-router-dom'; -import { useAuth } from '../../shared/hooks/use-auth'; -import { Box, CircularProgress, styled, Typography } from '@mui/material'; -import InputBox from '../../shared/components/atoms/input-box'; +import { Box, styled, Typography } from '@mui/material'; +import InputBox from '../../../shared/components/atoms/input-box'; import EmailIcon from '@mui/icons-material/Email'; import PersonIcon from '@mui/icons-material/Person'; import PasswordIcon from '@mui/icons-material/Password'; import AppRegistrationIcon from '@mui/icons-material/AppRegistration'; import LoginIcon from '@mui/icons-material/Login'; -import A2ZButton from '../../shared/components/atoms/button'; +import A2ZButton from '../../../shared/components/atoms/button'; import { useUserAuthForm } from './hooks'; +import { useState } from 'react'; +import A2ZTypography from '../../../shared/components/atoms/typography'; const StyledSection = styled('section')(({ theme }) => ({ paddingTop: theme.spacing(4), @@ -18,7 +18,7 @@ const StyledSection = styled('section')(({ theme }) => ({ display: 'flex', alignItems: 'center', justifyContent: 'center', - minHeight: `calc(100vh - 80px)`, + minHeight: '100vh', })); const StyledForm = styled('form')(() => ({ @@ -41,17 +41,15 @@ const StyledFooter = styled('p')(({ theme }) => ({ textAlign: 'center', })); -const UserAuthForm = ({ type }: { type: string }) => { - const { isAuthenticated } = useAuth(); - const { loading, handleSubmit } = useUserAuthForm({ type }); +const UserAuthForm = () => { + const [formType, setFormType] = useState('login'); + const { loading, handleSubmit } = useUserAuthForm({ type: formType }); - return isAuthenticated() ? ( - - ) : ( + return ( - {type === 'login' ? 'Welcome back' : 'Join us today'} + {formType === 'login' ? 'Welcome back' : 'Join us today'} { gap: 1, }} > - {type !== 'login' && ( + {formType !== 'login' && ( { loading={loading} loadingPosition="end" > - {type === 'login' ? 'Login' : 'Sign Up'} - {!loading ? ( - type === 'login' ? ( - - ) : ( - - ) - ) : ( - - )} + {formType === 'login' ? 'Login' : 'Sign Up'} + {formType === 'login' ? : } - {type === 'login' ? "Don't have an account ?" : 'Already a member ?'} - + setFormType(formType === 'login' ? 'signup' : 'login'), + sx: { + fontSize: '1.125rem', + marginLeft: 1, + textDecoration: 'underline', + color: 'inherit', + cursor: 'pointer', + '&:hover': { + opacity: 0.8, + }, + }, }} - > - {type === 'login' ? 'Join us today' : 'Sign in here'} - + /> diff --git a/client/src/shared/components/atoms/logo/index.tsx b/client/src/shared/components/atoms/logo/index.tsx new file mode 100644 index 00000000..2d4ef0af --- /dev/null +++ b/client/src/shared/components/atoms/logo/index.tsx @@ -0,0 +1,45 @@ +import { Box } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import A2ZTypography from '../typography'; + +const Logo = () => { + const navigate = useNavigate(); + + return ( + navigate('/')} + > + + + + ); +}; + +export default Logo; diff --git a/client/src/shared/components/atoms/scrollbar/index.tsx b/client/src/shared/components/atoms/scrollbar/index.tsx new file mode 100644 index 00000000..a4230e6e --- /dev/null +++ b/client/src/shared/components/atoms/scrollbar/index.tsx @@ -0,0 +1,55 @@ +/** @jsxImportSource @emotion/react */ +import { Global, css } from '@emotion/react'; + +const useScrollbar = () => { + const scrollbarStyle = css` + /* Total width */ + ::-webkit-scrollbar { + background-color: rgba(0, 0, 0, 0); + width: 10px; /* Increase width for spacing effect */ + height: 10px; + overflow: overlay; + scrollbar-color: #bcbcbc #f1f1f1; + } + + /* Background of the scrollbar track */ + ::-webkit-scrollbar-track { + background-color: rgba(0, 0, 0, 0); + } + + /* Scrollbar thumb */ + ::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0); /* Initial color of thumb */ + border-radius: 1px; /* Round the corners */ + border: 2px solid transparent; /* Transparent border to create spacing */ + background-clip: padding-box; /* Ensures background doesn’t overlap border */ + } + + /* Hover effect for scrollbar thumb */ + :hover::-webkit-scrollbar-thumb { + background-color: #bcbcbc; /* Change color on hover */ + } + + /* Scrollbar thumb hover effect */ + ::-webkit-scrollbar-thumb:hover { + background-color: #888; /* Darker color on thumb hover */ + } + + /* Hide scrollbar buttons (top and bottom of scrollbar) */ + ::-webkit-scrollbar-button { + display: none; + } + `; + + const GlobalScrollbar = () => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + if (isMac) return null; + return ; + }; + + return { + GlobalScrollbar, + }; +}; + +export default useScrollbar; diff --git a/client/src/shared/components/molecules/loader/index.tsx b/client/src/shared/components/molecules/loader/index.tsx new file mode 100644 index 00000000..e5654ff7 --- /dev/null +++ b/client/src/shared/components/molecules/loader/index.tsx @@ -0,0 +1,60 @@ +import { + Typography, + CircularProgress, + CircularProgressProps, + TypographyProps, + Box, + BoxProps, +} from '@mui/material'; +import { FC, ReactNode } from 'react'; + +interface LoaderProps { + secondary?: ReactNode; + primary?: ReactNode; + size?: string | number; + containerProps?: BoxProps; + loaderProps?: CircularProgressProps; + textProps?: TypographyProps; +} + +const Loader: FC = ({ + size, + loaderProps, + textProps, + containerProps, + primary, + secondary, +}) => { + return ( + + {primary ? primary : } + + {typeof secondary === 'string' ? ( + + {secondary} + + ) : ( + secondary + )} + + ); +}; + +export default Loader; diff --git a/client/src/shared/components/molecules/profile-avatar/index.tsx b/client/src/shared/components/molecules/profile-avatar/index.tsx new file mode 100644 index 00000000..ab714459 --- /dev/null +++ b/client/src/shared/components/molecules/profile-avatar/index.tsx @@ -0,0 +1,48 @@ +import { Avatar, Box } from '@mui/material'; +import { CSSProperties, useMemo } from 'react'; + +export const profileAvatarSize = 42; + +const ProfileAvatar = ({ + name = '', + profilePicture, + styles, +}: { + name?: string; + profilePicture?: string; + styles?: CSSProperties; +}) => { + const displayName = useMemo( + () => + (name ?? '') + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .trim() + ?.charAt(0) + .toUpperCase() ?? '0', + [name] + ); + + const avatarSx = { + width: `${profileAvatarSize}px`, + height: `${profileAvatarSize}px`, + borderRadius: '50%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginRight: '4px', + fontSize: '1rem', + fontWeight: 600, + textTransform: 'capitalize', + bgcolor: 'action.selected', + color: 'text.primary', + ...styles, + }; + + if (profilePicture) { + return ; + } + + return {displayName}; +}; + +export default ProfileAvatar; diff --git a/client/src/shared/components/organisms/header/constants/index.tsx b/client/src/shared/components/organisms/header/constants/index.tsx new file mode 100644 index 00000000..c4e09646 --- /dev/null +++ b/client/src/shared/components/organisms/header/constants/index.tsx @@ -0,0 +1 @@ +export const HEADER_HEIGHT = 55; // in pixels diff --git a/client/src/shared/components/organisms/header/index.tsx b/client/src/shared/components/organisms/header/index.tsx new file mode 100644 index 00000000..32bc4e7e --- /dev/null +++ b/client/src/shared/components/organisms/header/index.tsx @@ -0,0 +1,65 @@ +import { AppBar, Toolbar, Box, Badge } from '@mui/material'; +import LightModeIcon from '@mui/icons-material/LightMode'; +import DarkModeIcon from '@mui/icons-material/DarkMode'; +import A2ZIconButton from '../../atoms/icon-button'; +import Logo from '../../atoms/logo'; +import { useA2ZTheme } from '../../../hooks/use-theme'; +import { THEME } from '../../../states/theme'; +import { HEADER_HEIGHT } from './constants'; + +const Header = () => { + const { theme, setTheme } = useA2ZTheme(); + + return ( + + + + + + + + + + setTheme(theme === THEME.DARK ? THEME.LIGHT : THEME.DARK) + } + > + {theme === THEME.DARK ? : } + + + + + + ); +}; + +export default Header; diff --git a/client/src/shared/components/organisms/sidebar/components/SidebarMenuItem.tsx b/client/src/shared/components/organisms/sidebar/components/SidebarMenuItem.tsx new file mode 100644 index 00000000..29a5eccc --- /dev/null +++ b/client/src/shared/components/organisms/sidebar/components/SidebarMenuItem.tsx @@ -0,0 +1,211 @@ +import { + Box, + ButtonBase, + Tooltip, + TooltipProps, + useTheme, + SxProps, + Theme, +} from '@mui/material'; +import { FC, MouseEvent, useMemo, useCallback, ElementType } from 'react'; +import LockIcon from '@mui/icons-material/Lock'; +import { ROUTES_PAGE_V1 } from '../../../../../modules/app/routes/constants/routes'; +import { useCustomNavigate } from '../../../../hooks/use-custom-navigate'; +import { openPopup } from '../../../../utils/popup'; + +const SidebarMenuItem: FC<{ + Icon: ElementType; + hasAccess?: boolean; + path?: string; + title: TooltipProps['title']; + showExpandedView: boolean; + onClick?: (event: MouseEvent) => void; + style?: SxProps; + component?: () => React.ReactNode | void; + screenName?: ROUTES_PAGE_V1; + hideRipple?: boolean; + hide?: boolean; +}> = ({ + Icon, + title, + path, + hasAccess, + onClick, + showExpandedView = false, + style, + component, + screenName, + hideRipple, + hide, +}) => { + const navigate = useCustomNavigate(); + const theme = useTheme(); + + const getCurrentPage = useCallback(() => { + if (!path || !screenName) return false; + if (window.location.pathname === '/' && path === '/') { + return true; + } + if (path.includes(screenName) && window.location.pathname.includes(path)) { + return true; + } + return false; + }, [path, screenName]); + + const isCurrentPage = useMemo(() => getCurrentPage(), [getCurrentPage]); + + const iconColorValue = useMemo(() => { + if (isCurrentPage) { + return theme.palette.primary.main; + } + return showExpandedView + ? theme.palette.text.primary + : theme.palette.text.secondary; + }, [ + isCurrentPage, + showExpandedView, + theme.palette.primary.main, + theme.palette.text.primary, + theme.palette.text.secondary, + ]); + + const bgColor = useMemo( + () => (isCurrentPage ? theme.palette.action.selected : 'transparent'), + [isCurrentPage, theme.palette.action.selected] + ); + + const isLocked = typeof hasAccess !== 'undefined' && !hasAccess; + + const lockIcon = isLocked ? ( + + ) : null; + + const content = useMemo(() => { + if (typeof component === 'function') { + const componentResult = component(); + return componentResult || null; + } + return ( + + {title} + + ); + }, [component, title, iconColorValue]); + + const handleClick = useCallback( + (event: MouseEvent) => { + if (isLocked && !onClick) { + return; + } + + if (path) { + if ( + event.ctrlKey || + event.shiftKey || + event.metaKey || // apple + (event.button && event.button === 1) // middle click, >IE9 + everyone else + ) { + openPopup(path, '_blank'); + } else { + navigate({ pathname: path }, { clearExistingParams: true }); + } + } else { + onClick?.(event); + } + }, + [isLocked, onClick, path, navigate] + ); + + if (hide) return null; + + return ( + + + + + {lockIcon} + + + {content} + + + ); +}; + +export default SidebarMenuItem; diff --git a/client/src/shared/components/organisms/sidebar/constants/index.ts b/client/src/shared/components/organisms/sidebar/constants/index.ts new file mode 100644 index 00000000..fe4ca9cf --- /dev/null +++ b/client/src/shared/components/organisms/sidebar/constants/index.ts @@ -0,0 +1,3 @@ +export const SIDEBAR_WIDTH = 54; // in pixels + +export const ORGANISATION_TITLE_HEIGHT = 55; // in pixels diff --git a/client/src/shared/components/organisms/sidebar/hooks/index.ts b/client/src/shared/components/organisms/sidebar/hooks/index.ts index 6675607b..5eaff40a 100644 --- a/client/src/shared/components/organisms/sidebar/hooks/index.ts +++ b/client/src/shared/components/organisms/sidebar/hooks/index.ts @@ -1,5 +1,64 @@ +import { useCallback, useState, useMemo } from 'react'; +import HomeIcon from '@mui/icons-material/Home'; +import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { SideBarItemsType } from '../typings'; +import { + ROUTES_PAGE_V1, + ROUTES_V1, +} from '../../../../../modules/app/routes/constants/routes'; + +const logoutStyle = { + marginTop: 'auto', + marginBottom: 0, +}; + const useSidebar = () => { - return {}; + const [showExpandedView, setShowExpandedView] = useState(false); + + const handleMouseHoverIn = useCallback(() => { + setShowExpandedView(true); + }, []); + + const handleMouseHoverOut = useCallback(() => { + setShowExpandedView(false); + }, []); + + const sidebarItems = useMemo(() => { + const items: SideBarItemsType[] = [ + { + icon: HomeIcon, + path: ROUTES_V1.HOME, + title: 'Home', + screenName: ROUTES_PAGE_V1.HOME, + }, + { + icon: SettingsIcon, + path: ROUTES_V1.SETTINGS, + title: 'Settings', + screenName: ROUTES_PAGE_V1.SETTINGS, + }, + ]; + const secondaryItems: SideBarItemsType[] = [ + { + icon: PowerSettingsNewIcon, + // onClick: onLogout, + title: 'Logout', + style: logoutStyle, + }, + ]; + return { + items: items.filter(({ disable }) => !disable), + secondaryItems: secondaryItems.filter(({ disable }) => !disable), + }; + }, []); + + return { + showExpandedView, + handleMouseHoverIn, + handleMouseHoverOut, + sidebarItems, + }; }; export default useSidebar; diff --git a/client/src/shared/components/organisms/sidebar/index.tsx b/client/src/shared/components/organisms/sidebar/index.tsx index 7e2a7984..784a0406 100644 --- a/client/src/shared/components/organisms/sidebar/index.tsx +++ b/client/src/shared/components/organisms/sidebar/index.tsx @@ -1,256 +1,227 @@ -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'; - -import { - AppBar, - Box, - Divider, - Drawer, - IconButton, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Toolbar, - Typography, - useMediaQuery, - useTheme, -} from '@mui/material'; -import MenuIcon from '@mui/icons-material/Menu'; -import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; -import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; -import EditNoteOutlinedIcon from '@mui/icons-material/EditNoteOutlined'; -import PersonOutlineIcon from '@mui/icons-material/PersonOutline'; -import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; - -const drawerWidth = 240; +import { Box, Typography, SxProps, Theme, ButtonBase } from '@mui/material'; +import { ORGANISATION_TITLE_HEIGHT, SIDEBAR_WIDTH } from './constants'; +import useSidebar from './hooks'; +import SidebarMenuItem from './components/SidebarMenuItem'; +import { appVersion, ORGANISATION_NAME } from '../../../../config/env'; +import A2ZTypography from '../../atoms/typography'; +import ProfileAvatar from '../../molecules/profile-avatar'; const Sidebar = () => { - const user = useAtomValue(UserAtom); - const { isAuthenticated } = useAuth(); - const location = useLocation(); - const page = location.pathname.split('/')[2]; - - const [pageState, setPageState] = useState(page?.replace('-', ' ') || ''); - const [mobileOpen, setMobileOpen] = useState(false); - const [newNotificationAvailable, setNewNotificationAvailable] = - useState(false); - - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - - useEffect(() => { - const fetchNotificationStatus = async () => { - const response = await notificationStatus(); - if (response.status === 'success' && response.data) { - setNewNotificationAvailable(response.data.new_notification_available); - } - }; - fetchNotificationStatus(); - }, []); - - if (!user || !isAuthenticated()) { - return ; - } - - const handleDrawerToggle = () => { - setMobileOpen(prev => !prev); - }; + const { + showExpandedView, + handleMouseHoverIn, + handleMouseHoverOut, + sidebarItems, + } = useSidebar(); + const { items, secondaryItems } = sidebarItems; - const handleNavClick = (text: string) => { - setPageState(text); - if (isMobile) setMobileOpen(false); - }; - - const drawer = ( + return ( - - Dashboard - - - - - handleNavClick('Projects')} - > - - - - - - - handleNavClick('Notification')} - > - - - {newNotificationAvailable && ( - - )} - - - - - handleNavClick('Write')} - > - - - - - - - - - Settings - - - - - handleNavClick('Edit Profile')} - > - - - - - - - handleNavClick('Change Password')} - > - - - - - - - - ); - - return ( - - {/* Top bar for mobile */} - {isMobile && ( - - - - - - - {pageState} - - {/* spacer */} - - - )} + {showExpandedView ? ( + + ) : ( + + )} + + - {/* Sidebar Drawer */} - {/* Mobile Drawer */} - - {drawer} - - - {/* Desktop Drawer */} - - {drawer} - + index + ) => { + return ( + } + component={component} + screenName={screenName} + hideRipple={hideRipple} + hide={hide} + /> + ); + } + )} - {/* Main Content */} - + {secondaryItems.map( + ( + { + icon, + path, + onClick, + title, + style, + component, + screenName, + hasAccess, + hideRipple, + }, + index + ) => { + return ( + } + component={component} + screenName={screenName} + hideRipple={hideRipple} + /> + ); + } + )} + + {appVersion && ( + + + {showExpandedView ? `APP VERSION: ${appVersion}` : appVersion} + + + )} ); }; diff --git a/client/src/shared/components/organisms/sidebar/typings/index.ts b/client/src/shared/components/organisms/sidebar/typings/index.ts new file mode 100644 index 00000000..f328e33e --- /dev/null +++ b/client/src/shared/components/organisms/sidebar/typings/index.ts @@ -0,0 +1,18 @@ +import { SxProps, Theme } from '@mui/material'; +import { ReactNode, MouseEvent, ElementType } from 'react'; +import { ROUTES_PAGE_V1 } from '../../../../../modules/app/routes/constants/routes'; + +export type SideBarItemsType = { + icon: ElementType; + title: ReactNode; + label?: string; + path?: string; + screenName?: ROUTES_PAGE_V1; + onClick?: (event: MouseEvent) => void; + hasAccess?: boolean; + style?: SxProps; + component?: () => React.ReactNode | void; + disable?: boolean; + hideRipple?: boolean; + hide?: boolean; +}; diff --git a/client/src/shared/hooks/use-auth.ts b/client/src/shared/hooks/use-auth.ts index 03afcc0d..339bd8b1 100644 --- a/client/src/shared/hooks/use-auth.ts +++ b/client/src/shared/hooks/use-auth.ts @@ -42,7 +42,7 @@ export const useAuth = () => { setToken(null); setUser(null); clearToken(); - }, [setToken, setUser]); + }, [setToken, setUser, clearToken]); const login = (accessToken: string) => { setToken(accessToken); @@ -114,9 +114,9 @@ export const useAuth = () => { } }; - const isAuthenticated = () => { + const isAuthenticated = useCallback(() => { return !!token; - }; + }, [token]); const getAuthHeaders = () => { return { diff --git a/client/src/shared/hooks/use-custom-navigate.ts b/client/src/shared/hooks/use-custom-navigate.ts new file mode 100644 index 00000000..57cc705b --- /dev/null +++ b/client/src/shared/hooks/use-custom-navigate.ts @@ -0,0 +1,48 @@ +import { + NavigateOptions, + Path as NavigatePath, + useNavigate, + useLocation, +} from 'react-router-dom'; +import { useMemo } from 'react'; + +export const useCustomNavigate = () => { + const location = useLocation(); + const navigate = useNavigate(); + const customNavigate = useMemo(() => { + return ( + navigateTo: number | (Partial & { href?: string }), + navigateOptions?: NavigateOptions & { clearExistingParams?: boolean } + ): void => { + if (typeof navigateTo === 'number') { + navigate(navigateTo); + return; + } + const { pathname, search, hash, href } = navigateTo; + if (typeof href === 'string' && href.length > 0) { + navigate(href, navigateOptions); + return; + } + const existingUrlParams = new URLSearchParams(location.search); + const newUrlParams = new URLSearchParams(search); + const mergedUrlParams = new URLSearchParams( + navigateOptions?.clearExistingParams + ? Object.fromEntries(newUrlParams) + : { + ...Object.fromEntries(existingUrlParams), + ...Object.fromEntries(newUrlParams), + } + ).toString(); + navigate( + { + pathname, + search: mergedUrlParams.toString(), + hash: + typeof hash === 'string' && hash.length > 0 ? hash : location.hash, + }, + navigateOptions + ); + }; + }, [navigate, location.search, location.hash]); + return customNavigate; +}; diff --git a/client/src/shared/utils/popup.ts b/client/src/shared/utils/popup.ts new file mode 100644 index 00000000..96edba09 --- /dev/null +++ b/client/src/shared/utils/popup.ts @@ -0,0 +1,30 @@ +export const openPopup = (url: string, target?: string, features?: string) => { + const popup = openPopupUsingWindowOpen(url, target, features); + if (typeof popup?.focus === 'function') { + popup?.focus(); + } else { + openPopupUsingTag(url); + } + return popup; +}; + +const openPopupUsingWindowOpen = ( + url: string, + target?: string, + features?: string +) => { + return window.open(url, target, features); +}; + +const openPopupUsingTag = (url: string) => { + const anchorElement = document.createElement('a'); + anchorElement.href = url; + anchorElement.target = '_blank'; + anchorElement.rel = 'opener'; + const clickEvent = new MouseEvent('click', { + view: window, + bubbles: true, + cancelable: true, + }); + anchorElement.dispatchEvent(clickEvent); +};