Skip to content

Commit

Permalink
feat(ui): improve layout, sidebar, loading state
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
loklokyx committed Jan 17, 2025
1 parent bc9e539 commit e3b0c5a
Show file tree
Hide file tree
Showing 15 changed files with 322 additions and 89 deletions.
56 changes: 49 additions & 7 deletions client/src/components/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Navbar />
<main>{children}</main>
</div>
);
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<User>({
queryKey: ["user", userId],
endpoint: "/users/me",
staleTime: 5 * 60 * 1000,
enabled: Boolean(userId),
});

if (!isAuthChecked) return null;

if (!isLoggedIn) {
return (
<div>
<Navbar />
<main>{children}</main>
</div>
);
}

if (isLoading) return <WaitingLoader />;
if (!user) return <div>{error?.message || "Failed to load user data."}</div>;

return <Sidebar role={user.role}>{children}</Sidebar>;
}
32 changes: 0 additions & 32 deletions client/src/components/sidebar-layout.tsx

This file was deleted.

78 changes: 78 additions & 0 deletions client/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<SidebarProvider>
<AppSidebar className="z-50" Role={role} />
<SidebarInset>
<header className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-2 border-b bg-background px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-5" />
<Breadcrumb className="ml-4">
<BreadcrumbList className="text-xl">
{breadcrumbItems.map((item, index) => (
<React.Fragment key={item.url}>
<BreadcrumbItem>
<BreadcrumbLink href={item.url}>
{item.title}
</BreadcrumbLink>
</BreadcrumbItem>
{index < breadcrumbItems.length - 1 && (
<BreadcrumbSeparator className="[&>svg]:h-5 [&>svg]:w-5" />
)}
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</header>
<main>{children}</main>
</SidebarInset>
</SidebarProvider>
</div>
);
}
3 changes: 2 additions & 1 deletion client/src/components/ui/Leaderboard/leaderboard-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from "react";

import { WaitingLoader } from "@/components/ui/loading";
import { Search, SearchInput, SearchSelect } from "@/components/ui/search";
import {
Table,
Expand Down Expand Up @@ -34,7 +35,7 @@ export function LeaderboardList() {
endpoint: "leaderboard/list",
});

if (isLeaderboardLoading) return <div>Loading...</div>;
if (isLeaderboardLoading) return <WaitingLoader />;

if (isLeaderboardError) {
console.error("Error fetching leaderboards:", leaderboardError);
Expand Down
89 changes: 67 additions & 22 deletions client/src/components/ui/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<typeof Sidebar> {
Role: Role;
Role: Role | null;
}

/**
Expand All @@ -48,29 +50,64 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
* @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<string | null>(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 (
<Sidebar {...props}>
<SidebarContent className="gap-0">
<SidebarContent className="no-scrollbar gap-0 overflow-y-scroll">
<div className="flex items-center justify-center pt-2">
<Link href="/">
<Image
src="/wajo_white.svg"
alt="logo with white background"
width={100}
height={100}
className="cursor-pointer"
/>
</Link>
</div>
{roleNavData.map((section) => (
<SidebarGroup key={section.title}>
<SidebarGroup key={section.title} className="p-1.5 pb-0">
<SidebarMenu>
<Collapsible
key={section.title}
asChild
defaultOpen // make it open if it's active
key={section.title}
title={section.title}
open={openSection === section.title || section.isActive}
onOpenChange={(isOpen) =>
handleMenuToggle(isOpen ? section.title : null)
}
className="group/collapsible"
>
<SidebarMenuItem>
Expand Down Expand Up @@ -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)}
>
<a
<Link
href={item.url}
target={item.isNewTab ? "_blank" : "_self"}
rel={item.isNewTab ? "noopener noreferrer" : ""} // add security for _blank only
>
<span>{item.title}</span>
</a>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
Expand All @@ -117,10 +155,17 @@ export default function AppSidebar({ Role, ...props }: AppSidebarProps) {
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<SidebarFooter className="gap-0">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleLogout} aria-label="Logout">
<LogOut /> Logout
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarGroupLabel>Ctrl/Cmd + b to hide me</SidebarGroupLabel>
</SidebarFooter>
<SidebarRail />
{/* <SidebarRail /> */}
</Sidebar>
);
}
22 changes: 22 additions & 0 deletions client/src/components/ui/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 <WaitingLoader />;
*/
export function WaitingLoader() {
return (
<div className="flex justify-center pt-20">
<Button className="bg-transparent text-2xl" disabled>
<Loader className="mr-2 animate-spin" /> Loading...
</Button>
</div>
);
}
19 changes: 18 additions & 1 deletion client/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
*/}
<SheetTitle className="sr-only">Menu</SheetTitle>
<SheetDescription className="sr-only">
Description goes here
</SheetDescription>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
Expand Down
Loading

0 comments on commit e3b0c5a

Please sign in to comment.