Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions apps/web/src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
Children,
isValidElement,
memo,
useMemo,
type FC,
type PropsWithChildren,
type ReactNode,
} from "react";
import { SlideoutNavbar } from "./MobileSidebar";
import { AppShellContext } from "./AppShellContext";
import { useToggleState } from "react-stately";
import { Button } from "react-aria-components";
import TablerMenu2 from "~icons/tabler/menu-2";
import IconX from "~icons/tabler/x";

interface AppShellComponent extends FC<PropsWithChildren> {
Header: FC<PropsWithChildren>;
Navbar: FC<PropsWithChildren>;
Main: FC<PropsWithChildren>;
}

function extractAppShellChildren(children: ReactNode) {
let header: ReactNode = null;
let navbar: ReactNode = null;
let main: ReactNode = null;

Children.forEach(children, (child) => {
if (!isValidElement(child)) return;

if (child.type === AppShell.Header) header = child;
else if (child.type === AppShell.Navbar) navbar = child;
else if (child.type === AppShell.Main) main = child;
});

return { header, navbar, main };
}

const AppShellBase: FC<PropsWithChildren> = ({ children }) => {
const { toggle, isSelected, setSelected } = useToggleState();
const { header, navbar, main } = useMemo(
() => extractAppShellChildren(children),
[children],
);

return (
<AppShellContext.Provider
value={{
isMobileNavOpen: isSelected,
toggleMobileNav: toggle,
setMobileNavOpen: setSelected,
}}
>
<div className="flex h-screen w-screen flex-col relative">
{/* Topbar */}
{header && (
<header className="h-16 w-full flex items-center px-4 md:px-2 border-b-1 border-neutral-300 dark:border-neutral-800">
{/* Mobile Burger menu */}
<Button className="md:hidden" onPress={toggle}>
{isSelected ? (
<IconX className="w-8 h-8" />
) : (
<TablerMenu2 className="w-8 h-8" />
)}
</Button>
{header}
</header>
)}

<div className="flex flex-1">
{/* Desktop Sidebar */}
{navbar && (
<aside className="w-64 px-2 py-4 border-r border-neutral-300 dark:border-neutral-800 hidden md:block">
<nav className="flex flex-col gap-2">{navbar}</nav>
</aside>
)}

{/* Slideout for Mobile */}
<SlideoutNavbar isOpen={isSelected}>{navbar}</SlideoutNavbar>

{/* Main content */}
{main && <main className="flex-1 p-6 overflow-y-auto">{main}</main>}
</div>
</div>
</AppShellContext.Provider>
);
};

const AppShell = memo(AppShellBase) as unknown as AppShellComponent;

AppShell.Header = memo(({ children }: PropsWithChildren) => <>{children}</>);
AppShell.Navbar = memo(({ children }: PropsWithChildren) => <>{children}</>);
AppShell.Main = memo(({ children }: PropsWithChildren) => <>{children}</>);

AppShell.displayName = "AppShell";
AppShell.Header.displayName = "AppShell.Header";
AppShell.Navbar.displayName = "AppShell.Navbar";
AppShell.Main.displayName = "AppShell.Main";

export { AppShell };
17 changes: 17 additions & 0 deletions apps/web/src/components/AppShell/AppShellContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createContext, useContext } from "react";

export interface AppShellContextValue {
isMobileNavOpen: boolean;
toggleMobileNav: () => void;
setMobileNavOpen: (open: boolean) => void;
}

export const AppShellContext = createContext<AppShellContextValue | undefined>(
undefined,
);

export const useAppShell = () => {
const ctx = useContext(AppShellContext);
if (!ctx) throw new Error("useAppShell must be used within <AppShell>");
return ctx;
};
33 changes: 33 additions & 0 deletions apps/web/src/components/AppShell/MobileSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { PropsWithChildren } from "react";
import { useAppShell } from "./AppShellContext";

interface MobileSidebarProps extends PropsWithChildren {
isOpen: boolean;
}

const SlideoutNavbar = ({ children, isOpen }: MobileSidebarProps) => {
const { setMobileNavOpen } = useAppShell();

return (
<>
{/* Overlay */}
<div
onClick={() => setMobileNavOpen(false)}
className={`fixed top-16 left-0 right-0 bottom-0 transition-colors duration-300 ease-in-out z-40 ${
isOpen ? "bg-black/20" : "bg-black/0 pointer-events-none"
}`}
/>

{/* Side Navigation */}
<aside
className={`fixed top-16 left-0 h-full w-3/4 bg-background shadow-xl transform transition-transform duration-300 ease-in-out z-50 ${
isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<nav className="flex flex-col gap-2 px-2 py-3">{children}</nav>
</aside>
</>
);
};

export { SlideoutNavbar };
14 changes: 12 additions & 2 deletions apps/web/src/components/AppShell/NavLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { useToggleState } from "react-stately";
import { cn } from "@/utils/cn";
import { Button as RAC_Button } from "react-aria-components";
import { Link } from "@tanstack/react-router";
import { useAppShell } from "./AppShellContext";

const navLink = tv({
base: "px-3 py-2.5 rounded-sm text-sm flex flex-row items-center justify-between w-full cursor-pointer transition-none select-none text-navlink-text",
base: "px-3 py-2.5 rounded-sm text-md flex flex-row items-center justify-between w-full cursor-pointer transition-none select-none text-navlink-text",
variants: {
active: {
true: "bg-navlink-bg-active font-medium",
Expand All @@ -24,6 +25,7 @@ interface NavLinkProps {
rightSection?: ReactNode;
active?: boolean;
initialExpanded?: boolean;
closeNavbarOnClick?: boolean;
}

const NavLink = ({
Expand All @@ -34,13 +36,17 @@ const NavLink = ({
rightSection,
active = false,
initialExpanded = false,
closeNavbarOnClick = true,
children,
}: PropsWithChildren<NavLinkProps>) => {
const isExpandable = !!children;

const toggleState = useToggleState({ defaultSelected: initialExpanded });
const toggle = toggleState.toggle;

// Handle mobile navigation state in tangent with the AppShell context
const { setMobileNavOpen } = useAppShell();

return (
<div>
{isExpandable ? (
Expand Down Expand Up @@ -71,7 +77,11 @@ const NavLink = ({
</span>
</RAC_Button>
) : (
<Link className={navLink({ active })} to={href}>
<Link
className={navLink({ active })}
to={href}
onClick={() => (closeNavbarOnClick ? setMobileNavOpen(false) : null)}
>
<div className="flex flex-row gap-2 items-center">
{leftSection && (
<span className="flex items-center justify-center">
Expand Down
94 changes: 47 additions & 47 deletions apps/web/src/routes/_protected/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import TablerLayoutCollage from "~icons/tabler/layout-collage";
import TablerBooks from "~icons/tabler/books";
import TablerSocial from "~icons/tabler/social";
import { AppShell } from "@/components/AppShell/AppShell";

// This layout component performs authentication checks before the user can access protected pages
export const Route = createFileRoute("/_protected")({
Expand Down Expand Up @@ -46,56 +47,55 @@ function RouteComponent() {
const pathname = useLocation({ select: (loc) => loc.pathname });

return (
<div className="flex h-screen w-screen flex-col">
<header className="h-14 w-full bg-gray-800 text-white flex items-center px-4 shadow">
<h1 className="text-lg font-semibold">SwampHacks</h1>
</header>
<AppShell>
<AppShell.Header>
<div className="w-full px-4 flex flex-row justify-between h-full items-center">
<h1 className=" text-2xl font-bold">SwampHacks</h1>

<div className="flex flex-1">
<aside className="w-64 px-2 py-4 border-r-1 border-neutral-300 dark:border-neutral-800">
<nav className="flex flex-col gap-2">
<NavLink
label="Dashboard"
description="Hello everyone"
href="/dashboard"
leftSection={
<TablerLayoutCollage className="w-4 aspect-square" />
}
active={pathname.startsWith("/dashboard")}
/>
<img
src="https://i.pinimg.com/736x/8b/d2/f6/8bd2f653f38322972e404925ab67294a.jpg"
className="h-5/8 aspect-square rounded-full"
/>
</div>
</AppShell.Header>

<NavLink
label="Resources"
href="/_protected/resources"
initialExpanded={pathname.startsWith("/resources")}
leftSection={<TablerBooks className="w-4 aspect-square" />}
>
<NavLink
label="Programming"
href="/resources/programming"
active={pathname.startsWith("/resources/programming")}
/>
<NavLink
label="Sponsors"
href="/resources/sponsors"
active={pathname.startsWith("/resources/sponsors")}
/>
</NavLink>
<AppShell.Navbar>
<NavLink
label="Dashboard"
href="/dashboard"
leftSection={<TablerLayoutCollage className="w-5 aspect-square" />}
active={pathname.startsWith("/dashboard")}
/>

<NavLink
label="Community"
href="/community"
leftSection={<TablerSocial className="w-4 aspect-square" />}
active={pathname.startsWith("/community")}
/>
</nav>
</aside>
<NavLink
label="Resources"
href="/_protected/resources"
initialExpanded={pathname.startsWith("/resources")}
leftSection={<TablerBooks className="w-5 aspect-square" />}
>
<NavLink
label="Programming"
href="/resources/programming"
active={pathname.startsWith("/resources/programming")}
/>
<NavLink
label="Sponsors"
href="/resources/sponsors"
active={pathname.startsWith("/resources/sponsors")}
/>
</NavLink>

{/* Main content */}
<main className="flex-1 p-6 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
<NavLink
label="Community"
href="/community"
leftSection={<TablerSocial className="w-5 aspect-square" />}
active={pathname.startsWith("/community")}
/>
</AppShell.Navbar>

<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}
Loading