From e3b0c5a099b017306536476b4ace9a22f7cc2884 Mon Sep 17 00:00:00 2001 From: Lok Yx Date: Fri, 17 Jan 2025 12:05:45 +0000 Subject: [PATCH] feat(ui): improve layout, sidebar, loading state - feat(ui): create `WaitingLoader` component to show loading state during requests - feat(layout): get layout based on login status and role. Add breadcrumb navigation links - feat(sidebar): add logo to navigate landing page, add logout button - fix(sidebar): add missing `SheetTitle` and `SheetDescription` in Sidebar - feat(api): add `/users/me` mock data API - style(ui): add no-scrollbar CSS for better layout appearance --- client/src/components/layout.tsx | 56 ++++++++++-- client/src/components/sidebar-layout.tsx | 32 ------- client/src/components/sidebar.tsx | 78 ++++++++++++++++ .../ui/Leaderboard/leaderboard-list.tsx | 3 +- client/src/components/ui/app-sidebar.tsx | 89 ++++++++++++++----- client/src/components/ui/loading.tsx | 22 +++++ client/src/components/ui/sidebar.tsx | 19 +++- client/src/pages/_app.tsx | 11 ++- client/src/pages/api/users/me.ts | 56 ++++++++++++ client/src/pages/index.tsx | 11 +-- client/src/pages/question/index.tsx | 16 +--- client/src/pages/users/index.tsx | 3 +- client/src/pages/users/school.tsx | 3 +- client/src/pages/users/team.tsx | 3 +- client/src/styles/globals.css | 9 ++ 15 files changed, 322 insertions(+), 89 deletions(-) delete mode 100644 client/src/components/sidebar-layout.tsx create mode 100644 client/src/components/sidebar.tsx create mode 100644 client/src/components/ui/loading.tsx create mode 100644 client/src/pages/api/users/me.ts diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index 5d2bc57..fe85e83 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -1,16 +1,58 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import Navbar from "@/components/navbar"; +import { WaitingLoader } from "@/components/ui/loading"; +import { useAuth } from "@/context/auth-provider"; +import { useFetchData } from "@/hooks/use-fetch-data"; +import { User } from "@/types/user"; + +import Sidebar from "./sidebar"; interface LayoutProps { children: React.ReactNode; } +/** + * Layout component that wraps the application with a Navbar or Sidebar based on user authentication status. + * + * @param {LayoutProps} props - The component props. + * @param {React.ReactNode} props.children - The child components to render within the layout. + * + */ export default function Layout({ children }: LayoutProps) { - return ( -
- -
{children}
-
- ); + const [isAuthChecked, setIsAuthChecked] = useState(false); + const { userId } = useAuth(); + const isLoggedIn = Boolean(userId); + + // wait for auth to be checked before rendering + useEffect(() => { + setIsAuthChecked(true); + }, []); + + const { + data: user, + error, + isLoading, + } = useFetchData({ + queryKey: ["user", userId], + endpoint: "/users/me", + staleTime: 5 * 60 * 1000, + enabled: Boolean(userId), + }); + + if (!isAuthChecked) return null; + + if (!isLoggedIn) { + return ( +
+ +
{children}
+
+ ); + } + + if (isLoading) return ; + if (!user) return
{error?.message || "Failed to load user data."}
; + + return {children}; } diff --git a/client/src/components/sidebar-layout.tsx b/client/src/components/sidebar-layout.tsx deleted file mode 100644 index 5e01456..0000000 --- a/client/src/components/sidebar-layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; - -import AppSidebar from "@/components/ui/app-sidebar"; -import { Separator } from "@/components/ui/separator"; -import { - SidebarInset, - SidebarProvider, - SidebarTrigger, -} from "@/components/ui/sidebar"; -import { Role } from "@/types/user"; - -interface LayoutProps { - children: React.ReactNode; - role: Role; -} - -export default function SidebarLayout({ children, role }: LayoutProps) { - return ( -
- - - -
- - -
-
{children}
-
-
-
- ); -} diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx new file mode 100644 index 0000000..00321dd --- /dev/null +++ b/client/src/components/sidebar.tsx @@ -0,0 +1,78 @@ +import { useRouter } from "next/router"; +import React from "react"; + +import AppSidebar from "@/components/ui/app-sidebar"; +import { Separator } from "@/components/ui/separator"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { Role } from "@/types/user"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "./ui/breadcrumb"; + +interface LayoutProps { + children: React.ReactNode; + role: Role; +} + +/** + * Sidebar layout component that renders a sidebar with breadcrumb navigation. + * Displays different content based on the user's role. + * + * @param {LayoutProps} props - The component props. + * @param {React.ReactNode} props.children - The content to render inside the sidebar layout. + * @param {Role} props.role - The role of the user, used to control sidebar behavior. + */ +export default function Sidebar({ children, role }: LayoutProps) { + const router = useRouter(); + const pathSegments = router.pathname.split("/").filter(Boolean); + + const breadcrumbItems = pathSegments.map((segment, index) => { + const formattedSegment = segment + .replace(/_/g, " ") // replace underscores with spaces + .replace(/\b\w/g, (char) => char.toUpperCase()); // capitalize first letter + return { + title: formattedSegment, + url: `/${pathSegments.slice(0, index + 1).join("/")}`, + }; + }); + + return ( +
+ + + +
+ + + + + {breadcrumbItems.map((item, index) => ( + + + + {item.title} + + + {index < breadcrumbItems.length - 1 && ( + + )} + + ))} + + +
+
{children}
+
+
+
+ ); +} diff --git a/client/src/components/ui/Leaderboard/leaderboard-list.tsx b/client/src/components/ui/Leaderboard/leaderboard-list.tsx index e2c4c96..2d15ec6 100644 --- a/client/src/components/ui/Leaderboard/leaderboard-list.tsx +++ b/client/src/components/ui/Leaderboard/leaderboard-list.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; +import { WaitingLoader } from "@/components/ui/loading"; import { Search, SearchInput, SearchSelect } from "@/components/ui/search"; import { Table, @@ -34,7 +35,7 @@ export function LeaderboardList() { endpoint: "leaderboard/list", }); - if (isLeaderboardLoading) return
Loading...
; + if (isLeaderboardLoading) return ; if (isLeaderboardError) { console.error("Error fetching leaderboards:", leaderboardError); diff --git a/client/src/components/ui/app-sidebar.tsx b/client/src/components/ui/app-sidebar.tsx index c5c8bb0..2b43e6e 100644 --- a/client/src/components/ui/app-sidebar.tsx +++ b/client/src/components/ui/app-sidebar.tsx @@ -1,6 +1,8 @@ -import { ChevronRight } from "lucide-react"; +import { ChevronRight, LogOut } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; import { useRouter } from "next/router"; -import React from "react"; +import React, { useMemo, useState } from "react"; import { Collapsible, @@ -19,14 +21,14 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, - SidebarRail, } from "@/components/ui/sidebar"; +import { useAuth } from "@/context/auth-provider"; import { cn } from "@/lib/utils"; import { navData } from "@/types/app-sidebar"; import { Role } from "@/types/user"; interface AppSidebarProps extends React.ComponentProps { - Role: Role; + Role: Role | null; } /** @@ -48,29 +50,64 @@ interface AppSidebarProps extends React.ComponentProps { * @param {Role} props.Role - The role of the user (e.g., Admin, Staff, Student), used to determine the navigation items to display. */ export default function AppSidebar({ Role, ...props }: AppSidebarProps) { + const { logout } = useAuth(); const router = useRouter(); - const roleNavData = navData[Role]; - roleNavData.forEach((section) => { - for (const item of section.items) { - const regex = new RegExp(`^${item.url.replace(/\[.*?\]/g, ".*")}$`); - item.isActive = regex.test(router.pathname); - if (item.isActive && !section.isActive) { - section.isActive = true; - } - } - }); + const [openSection, setOpenSection] = useState(null); + + // Memoize role-based navigation data to avoid recalculating on every render. + const roleNavData = useMemo(() => { + if (!Role) return []; + + const isPathActive = (path: string) => { + const regex = new RegExp(`^${path.replace(/\[.*?\]/g, ".*")}$`); + return regex.test(router.pathname); + }; + + return navData[Role].map((section) => ({ + ...section, + isActive: section.items.some((item) => isPathActive(item.url)), + items: section.items.map((item) => ({ + ...item, + isActive: isPathActive(item.url), + })), + })); + }, [Role, router.pathname]); + + const handleMenuToggle = (sectionTitle: string | null) => { + setOpenSection((prev) => (prev === sectionTitle ? null : sectionTitle)); + }; + + const handleLogout = () => { + router.push("/"); + logout(); + }; return ( - + +
+ + logo with white background + +
{roleNavData.map((section) => ( - + + handleMenuToggle(isOpen ? section.title : null) + } className="group/collapsible" > @@ -98,14 +135,15 @@ export default function AppSidebar({ Role, ...props }: AppSidebarProps) { asChild isActive={item.isActive} className="hover:bg-yellow data-[active=true]:bg-yellow" + onClick={() => handleMenuToggle(section.title)} > - {item.title} - + ))} @@ -117,10 +155,17 @@ export default function AppSidebar({ Role, ...props }: AppSidebarProps) { ))}
- + + + + + Logout + + + Ctrl/Cmd + b to hide me - + {/* */}
); } diff --git a/client/src/components/ui/loading.tsx b/client/src/components/ui/loading.tsx new file mode 100644 index 0000000..63e624b --- /dev/null +++ b/client/src/components/ui/loading.tsx @@ -0,0 +1,22 @@ +import { Loader } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +/** + * WaitingLoader component displays a loading spinner inside a disabled button, indicating that content is being loaded. + * + * **Usage:** + * Simply include where you need to display a loading state within the UI. + * + * @example + * if (isLoading) return ; + */ +export function WaitingLoader() { + return ( +
+ +
+ ); +} diff --git a/client/src/components/ui/sidebar.tsx b/client/src/components/ui/sidebar.tsx index 782a4c0..af71e81 100644 --- a/client/src/components/ui/sidebar.tsx +++ b/client/src/components/ui/sidebar.tsx @@ -6,7 +6,12 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; -import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, +} from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, @@ -223,6 +228,18 @@ const Sidebar = React.forwardRef< side={side} aria-label="Sidebar Sheet Content" > + {/* + Added SheetTitle, SheetDescription for accessibility to avoid console errors. + 'sr-only' class only visible to screen readers, not visually to the user. + References: + https://stackoverflow.com/questions/78728076/shadcn-dialogcontent-requires-a-dialogtitle-for-the-component-to-be-accessib + https://stackoverflow.com/questions/78728117/shadcn-warning-missing-description-or-aria-describedby-undefined-for-dia + https://tailwindcss.com/docs/screen-readers + */} + Menu + + Description goes here +
{children}
diff --git a/client/src/pages/_app.tsx b/client/src/pages/_app.tsx index 2b0bd56..3a8e5d4 100644 --- a/client/src/pages/_app.tsx +++ b/client/src/pages/_app.tsx @@ -7,6 +7,7 @@ import type { AppProps } from "next/app"; import { Roboto, Urbanist } from "next/font/google"; import type { ReactElement, ReactNode } from "react"; +import Layout from "@/components/layout"; import { Toaster } from "@/components/ui/sonner"; import { AuthProvider } from "@/context/auth-provider"; @@ -37,7 +38,13 @@ type AppPropsWithLayout = AppProps & { const queryClient = new QueryClient(); export default function App({ Component, pageProps }: AppPropsWithLayout) { - const getLayout = Component.getLayout ?? ((page) => page); + const myLayout = Component.getLayout ? ( + Component.getLayout() + ) : ( + + + + ); return ( <> @@ -50,7 +57,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) { - {getLayout()} + {myLayout} diff --git a/client/src/pages/api/users/me.ts b/client/src/pages/api/users/me.ts new file mode 100644 index 0000000..2aa422f --- /dev/null +++ b/client/src/pages/api/users/me.ts @@ -0,0 +1,56 @@ +/** + * API Route Handler for fetching user data. + * + * This handler serves mock user data for demonstration purposes. + * It is accessible at the `/api/users/me` endpoint. + * + * @fileoverview Provides an API route for retrieving mock user data. + * @module api/users/me + */ + +import { NextApiRequest, NextApiResponse } from "next"; + +import { User } from "@/types/user"; + +/** + * Mock user data for demonstration purposes. + * + * @constant {User} + */ +const mockUser: User = { + id: 1, + username: "johndoe", + email: "johndoe@example.com", + first_name: "John", + last_name: "Doe", + role: "admin", + school: "University of Western Australia", +}; + +/** + * API route handler to fetch user data. + * + * @function handler + * @param {NextApiRequest} _req - The incoming API request object. Ignored in this mock implementation. + * @param {NextApiResponse} res - The outgoing API response object containing the mock user data. + * + * @returns {void} Responds with a status code of 200 and the mock user data in JSON format. + * + * @example + * // Example response payload: + * // { + * // "id": 1, + * // "username": "johndoe", + * // "email": "johndoe@example.com", + * // "first_name": "John", + * // "last_name": "Doe", + * // "role": "admin", + * // "school": "University of Western Australia" + * // } + */ +export default function handler( + _req: NextApiRequest, + res: NextApiResponse, +): void { + res.status(200).json(mockUser); +} diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 7adb90e..2660452 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -2,10 +2,9 @@ import { useState } from "react"; import { usePings } from "@/hooks/pings"; -import Layout from "../components/layout"; import { Button } from "../components/ui/button"; -const Home = () => { +export default function Home() { const [clicked, setClicked] = useState(false); const { data, isLoading } = usePings({ enabled: clicked, @@ -27,10 +26,4 @@ const Home = () => {

); -}; - -Home.getLayout = function getLayout(page: React.ReactElement) { - return {page}; -}; - -export default Home; +} diff --git a/client/src/pages/question/index.tsx b/client/src/pages/question/index.tsx index c31c6d6..6d5d303 100644 --- a/client/src/pages/question/index.tsx +++ b/client/src/pages/question/index.tsx @@ -1,13 +1,13 @@ import Link from "next/link"; import React, { useEffect, useState } from "react"; -import SidebarLayout from "@/components/sidebar-layout"; import { Button } from "@/components/ui/button"; +import { WaitingLoader } from "@/components/ui/loading"; import { Datagrid } from "@/components/ui/Question/data-grid"; import { SearchInput } from "@/components/ui/search"; import { useFetchData } from "@/hooks/use-fetch-data"; -const Index = () => { +export default function Index() { // Fetches the list of questions using the custom hook. const { data: questions, @@ -50,9 +50,7 @@ const Index = () => { }; // Displays a loading state while data is being fetched. - if (isQuestionLoading) { - return
Loading...
; - } + if (isQuestionLoading) return ; // Displays an error message if the API request fails. if (isQuestionError) { @@ -84,10 +82,4 @@ const Index = () => { > ); -}; - -Index.getLayout = function getLayout(page: React.ReactElement) { - return {page}; -}; - -export default Index; +} diff --git a/client/src/pages/users/index.tsx b/client/src/pages/users/index.tsx index 5f807b8..7d294b2 100644 --- a/client/src/pages/users/index.tsx +++ b/client/src/pages/users/index.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import React, { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { WaitingLoader } from "@/components/ui/loading"; import { SearchInput } from "@/components/ui/search"; import { DataGrid } from "@/components/ui/Users/data-grid"; import { useFetchData } from "@/hooks/use-fetch-data"; @@ -47,7 +48,7 @@ export default function UserList() { setPage(1); }; - if (isUserLoading) return
Loading...
; + if (isUserLoading) return ; if (isUserError) return
Error: {UserError?.message}
; return ( diff --git a/client/src/pages/users/school.tsx b/client/src/pages/users/school.tsx index 6cbc1d4..2395a99 100644 --- a/client/src/pages/users/school.tsx +++ b/client/src/pages/users/school.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import React, { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { WaitingLoader } from "@/components/ui/loading"; import { SearchInput } from "@/components/ui/search"; import { SchoolDataGrid } from "@/components/ui/Users/school-data-grid"; import { useFetchData } from "@/hooks/use-fetch-data"; @@ -47,7 +48,7 @@ export default function SchoolList() { setPage(1); }; - if (isSchoolLoading) return
Loading...
; + if (isSchoolLoading) return ; if (isSchoolError) return
Error: {schoolError?.message}
; return ( diff --git a/client/src/pages/users/team.tsx b/client/src/pages/users/team.tsx index 75e93f6..4122564 100644 --- a/client/src/pages/users/team.tsx +++ b/client/src/pages/users/team.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import React, { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { WaitingLoader } from "@/components/ui/loading"; import { SearchInput } from "@/components/ui/search"; import { TeamDataGrid } from "@/components/ui/Users/team-data-grid"; import { useFetchData } from "@/hooks/use-fetch-data"; @@ -47,7 +48,7 @@ export default function TeamList() { setPage(1); }; - if (isTeamLoading) return
Loading...
; + if (isTeamLoading) return ; if (isTeamError) return
Error: {TeamError?.message}
; return ( diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index 058fb96..2015f1c 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -162,6 +162,15 @@ font-weight: 600; line-height: 24px; } + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } } @layer base {