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 (
-
+
+
+
+
+
+
{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 {