diff --git a/apps/web/package.json b/apps/web/package.json index 0bef44d7..55eed01f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,13 +20,16 @@ "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.0.3", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.6", "@radix-ui/react-select": "^1.2.2", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tooltip": "^1.2.8", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-table": "^8.19.3", diff --git a/apps/web/src/app/admin/events/edit/[slug]/page.tsx b/apps/web/src/app/admin/events/edit/[slug]/page.tsx index aa2c4346..95e3a7dd 100644 --- a/apps/web/src/app/admin/events/edit/[slug]/page.tsx +++ b/apps/web/src/app/admin/events/edit/[slug]/page.tsx @@ -28,7 +28,7 @@ export default async function EditEventPage({ } return ( -
+

Edit Event diff --git a/apps/web/src/app/admin/events/new/page.tsx b/apps/web/src/app/admin/events/new/page.tsx index 2de0a160..95ac9597 100644 --- a/apps/web/src/app/admin/events/new/page.tsx +++ b/apps/web/src/app/admin/events/new/page.tsx @@ -12,7 +12,7 @@ export default async function Page() { const defaultDate = new Date(); return ( -
+

New Event

diff --git a/apps/web/src/app/admin/events/page.tsx b/apps/web/src/app/admin/events/page.tsx index 1120e5cc..0e8d25f9 100644 --- a/apps/web/src/app/admin/events/page.tsx +++ b/apps/web/src/app/admin/events/page.tsx @@ -25,7 +25,7 @@ export default async function Page() { PermissionType.CREATE_EVENTS, ); return ( -
+
diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 6ce3f59a..a28aff89 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -1,15 +1,17 @@ -import c from "config"; -import Image from "next/image"; -import Link from "next/link"; -import { Button } from "@/components/shadcn/ui/button"; -import DashNavItem from "@/components/dash/shared/DashNavItem"; import FullScreenMessage from "@/components/shared/FullScreenMessage"; -import ProfileButton from "@/components/shared/ProfileButton"; import React, { Suspense } from "react"; import ClientToast from "@/components/shared/ClientToast"; -import { isUserAdmin, userHasPermission } from "../../lib/utils/server/admin"; -import { PermissionType } from "@/lib/constants/permission"; +import { isUserAdmin } from "../../lib/utils/server/admin"; import { getCurrentUser } from "@/lib/utils/server/user"; +import { AdminSidebar } from "@/components/admin/shared/sidebar/AdminSidebar"; +import { Separator } from "@/components/shadcn/ui/separator"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/shadcn/ui/sidebar"; +import { AdminBreadcrumbs } from "@/components/admin/shared/AdminBreadcrumbs"; +import { NavUserProfile } from "@/components/admin/shared/NavUserProfile"; interface AdminLayoutProps { children: React.ReactNode; @@ -19,11 +21,10 @@ export default async function AdminLayout({ children }: AdminLayoutProps) { const user = await getCurrentUser(); if (!isUserAdmin(user)) { - console.log("Denying admin access to user", user); return ( ); } @@ -31,76 +32,29 @@ export default async function AdminLayout({ children }: AdminLayoutProps) { return ( <> -
-
- - {c.hackathonName -
-

Admin

- -
-
- - - - - - - - - - -
-
-
-
- {Object.entries(c.dashPaths.admin).map(([name, path]) => { - // Gate specific admin nav items by permission - if ( - name === "Users" && - !userHasPermission(user, PermissionType.VIEW_USERS) - ) - return null; - if ( - name === "Events" && - !userHasPermission(user, PermissionType.VIEW_EVENTS) - ) - return null; - if ( - name === "Roles" && - !userHasPermission(user, PermissionType.VIEW_ROLES) - ) - return null; - if ( - name === "Toggles" && - !userHasPermission(user, PermissionType.MANAGE_NAVLINKS) - ) - return null; - // Keep other configured admin paths visible by default - return ; - })} -
- Loading...

}>{children}
+ + + +
+
+ + + +
+
+ +
+
+
+ Loading...

}> + {children} +
+
+
+
); } diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index e02bb336..79b06ed7 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -30,7 +30,7 @@ export default async function Page() { const timezone = getClientTimeZone(c.hackathonTimezone); return ( -
+

Welcome,

diff --git a/apps/web/src/app/admin/roles/page.tsx b/apps/web/src/app/admin/roles/page.tsx index f5bcc550..2c76b73c 100644 --- a/apps/web/src/app/admin/roles/page.tsx +++ b/apps/web/src/app/admin/roles/page.tsx @@ -15,7 +15,7 @@ export default async function Page() { // pass serializable data to client return ( -
+

Roles

+

Navbar Items diff --git a/apps/web/src/app/admin/toggles/layout.tsx b/apps/web/src/app/admin/toggles/layout.tsx index db8db932..0a2eff34 100644 --- a/apps/web/src/app/admin/toggles/layout.tsx +++ b/apps/web/src/app/admin/toggles/layout.tsx @@ -16,7 +16,7 @@ export default async function Layout({ children }: ToggleLayoutProps) { return notFound(); return ( -
+
diff --git a/apps/web/src/app/admin/users/[slug]/page.tsx b/apps/web/src/app/admin/users/[slug]/page.tsx index d4cfcd23..a72ce8b9 100644 --- a/apps/web/src/app/admin/users/[slug]/page.tsx +++ b/apps/web/src/app/admin/users/[slug]/page.tsx @@ -48,7 +48,7 @@ export default async function Page({ params }: { params: { slug: string } }) { }); return ( -
+
{!!banInstance && (
diff --git a/apps/web/src/app/admin/users/page.tsx b/apps/web/src/app/admin/users/page.tsx index aebc72e2..d1a6d16b 100644 --- a/apps/web/src/app/admin/users/page.tsx +++ b/apps/web/src/app/admin/users/page.tsx @@ -17,7 +17,7 @@ export default async function Page() { const userData = await getAllUsers(); return ( -
+
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 8494cab5..69b2adac 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -42,6 +42,14 @@ --gradient-color-2: #3366ff; --gradient-color-3: #002db3; --gradient-color-4: #1952cc; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { @@ -75,6 +83,22 @@ --destructive-foreground: 0 85.7% 97.3%; --ring: 240 3.7% 15.9%; + + --sidebar-background: 240 5.9% 10%; + + --sidebar-foreground: 240 4.8% 95.9%; + + --sidebar-primary: 224.3 76.3% 48%; + + --sidebar-primary-foreground: 0 0% 100%; + + --sidebar-accent: 240 3.7% 15.9%; + + --sidebar-accent-foreground: 240 4.8% 95.9%; + + --sidebar-border: 240 3.7% 15.9%; + + --sidebar-ring: 217.2 91.2% 59.8%; } } diff --git a/apps/web/src/components/admin/scanner/CheckinScanner.tsx b/apps/web/src/components/admin/scanner/CheckinScanner.tsx index 8b82565e..613fad0a 100644 --- a/apps/web/src/components/admin/scanner/CheckinScanner.tsx +++ b/apps/web/src/components/admin/scanner/CheckinScanner.tsx @@ -127,9 +127,9 @@ export default function CheckinScanner({ return ( <> -
-
-
+
+
+
{ const params = new URLSearchParams( @@ -156,7 +156,7 @@ export default function CheckinScanner({ onError={(error) => console.log(error)} styles={{ container: { - width: "100vw", + width: "100%", maxWidth: "500px", margin: "0", }, diff --git a/apps/web/src/components/admin/shared/AdminBreadcrumbs.tsx b/apps/web/src/components/admin/shared/AdminBreadcrumbs.tsx new file mode 100644 index 00000000..6024f89e --- /dev/null +++ b/apps/web/src/components/admin/shared/AdminBreadcrumbs.tsx @@ -0,0 +1,70 @@ +// app/admin/_components/admin-breadcrumbs.tsx +"use client"; + +import Link from "next/link"; +import { useSelectedLayoutSegments } from "next/navigation"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/shadcn/ui/breadcrumb"; +import { BreadcrumbLabels } from "@/lib/constants/admin"; +import React from "react"; + +function formatSegment(segment: string) { + return ( + BreadcrumbLabels[segment] ?? + decodeURIComponent(segment) + .replace(/-/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) + ); +} + +export function AdminBreadcrumbs() { + const segments = useSelectedLayoutSegments(); + + const visibleSegments = segments.filter( + (segment) => !segment.startsWith("(") && !segment.startsWith("@"), + ); + + const allSegments = ["admin", ...visibleSegments]; + + return ( + + + {allSegments.map((segment, index) => { + const href = + "/" + allSegments.slice(0, index + 1).join("/"); + const isLast = index === allSegments.length - 1; + + return ( + + + {isLast ? ( + + {formatSegment(segment)} + + ) : ( + + + {formatSegment(segment)} + + + )} + + + {!isLast && ( + + )} + + ); + })} + + + ); +} diff --git a/apps/web/src/components/admin/shared/NavUserProfile.tsx b/apps/web/src/components/admin/shared/NavUserProfile.tsx new file mode 100644 index 00000000..e62ce4c6 --- /dev/null +++ b/apps/web/src/components/admin/shared/NavUserProfile.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { ChevronsUpDown } from "lucide-react"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/shadcn/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/shadcn/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/shadcn/ui/sidebar"; +import { UserWithRole } from "db/types"; +import { SignOutButton } from "@clerk/nextjs"; +import Link from "next/link"; +import { DropdownSwitcher } from "@/components/shared/ThemeSwitcher"; +import Restricted from "@/components/Restricted"; +import { PermissionType } from "@/lib/constants/permission"; + +export function NavUserProfile({ user }: { user: UserWithRole }) { + return ( + + + + + + + + + {user.firstName.charAt(0) + + user.lastName.charAt(0)} + + +
+ + {user.firstName + " " + user.lastName} + + + {user.email} + +
+
+
+ + + + + Profile + + + + + Event Pass + + + + + + + Admin + + + + + + + Report a Bug + + + + + Settings + + + + + + + + Sign out + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/admin/shared/sidebar/AdminSidebar.tsx b/apps/web/src/components/admin/shared/sidebar/AdminSidebar.tsx new file mode 100644 index 00000000..77650268 --- /dev/null +++ b/apps/web/src/components/admin/shared/sidebar/AdminSidebar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import * as React from "react"; +import Image from "next/image"; +import Link from "next/link"; + +import { NavMain } from "./NavMain"; +import { NavSecondary } from "./NavSecondary"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/shadcn/ui/sidebar"; +import c from "config"; +import { UserWithRole } from "db/types"; +import { userHasPermission } from "@/lib/utils/server/admin"; +import { adminSidebarData as data } from "@/lib/constants/admin"; + +export function AdminSidebar({ + user, + ...props +}: React.ComponentProps & { + user?: UserWithRole; +}) { + const mainItems = data.navMain.filter((item) => + item.permission && user + ? userHasPermission(user, item.permission) + : !item.permission || !user, + ); + const secondaryItems = data.navSecondary.filter((item) => + item.permission && user + ? userHasPermission(user, item.permission) + : !item.permission || !user, + ); + + return ( + + + + + +
+ + {c.hackathonName +
+

+ Admin +

+ +
+ + + + + + + + + +
+ + Powered by HackKit + +
+
+ + ); +} diff --git a/apps/web/src/components/admin/shared/sidebar/NavMain.tsx b/apps/web/src/components/admin/shared/sidebar/NavMain.tsx new file mode 100644 index 00000000..b753e471 --- /dev/null +++ b/apps/web/src/components/admin/shared/sidebar/NavMain.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { ChevronRight, type LucideIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/shadcn/ui/collapsible"; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/shadcn/ui/sidebar"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + items?: { + title: string; + url: string; + }[]; + }[]; +}) { + const pathname = usePathname(); + + const isUrlActive = (itemUrl: string) => { + if (!pathname) return false; + return ( + pathname === itemUrl || + pathname.startsWith(`${itemUrl}/`) || + pathname.startsWith(`${itemUrl}?`) + ); + }; + + const isUrlExactActive = (itemUrl: string) => { + if (!pathname) return false; + return pathname === itemUrl || pathname.startsWith(`${itemUrl}?`); + }; + + return ( + + + {items.map((item) => { + const hasSubItems = (item.items?.length ?? 0) > 0; + const isItemActiveSelf = isUrlExactActive(item.url); + const isAnySubItemActive = item.items?.some((subItem) => + isUrlExactActive(subItem.url), + ); + const shouldBeOpen = + hasSubItems && + (isUrlActive(item.url) || isAnySubItemActive); + + return ( + + + + + + {item.title} + + + {hasSubItems ? ( + <> + + + + + + + + {item.items?.map((subItem) => { + const isSubItemActive = + isUrlActive( + subItem.url, + ); + + return ( + + + + + { + subItem.title + } + + + + + ); + })} + + + + ) : null} + + + ); + })} + + + ); +} diff --git a/apps/web/src/components/admin/shared/sidebar/NavSecondary.tsx b/apps/web/src/components/admin/shared/sidebar/NavSecondary.tsx new file mode 100644 index 00000000..54eb13b8 --- /dev/null +++ b/apps/web/src/components/admin/shared/sidebar/NavSecondary.tsx @@ -0,0 +1,67 @@ +"use client"; + +import * as React from "react"; +import { type LucideIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/shadcn/ui/sidebar"; + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + }[]; +} & React.ComponentPropsWithoutRef) { + const pathname = usePathname(); + + const isUrlActive = (itemUrl: string) => { + if (!pathname) return false; + return ( + pathname === itemUrl || + pathname.startsWith(`${itemUrl}/`) || + pathname.startsWith(`${itemUrl}?`) + ); + }; + + return ( + + + + {items.map((item) => { + const isActive = isUrlActive(item.url); + + return ( + + + + + {item.title} + + + + ); + })} + + + + ); +} diff --git a/apps/web/src/components/shadcn/ui/breadcrumb.tsx b/apps/web/src/components/shadcn/ui/breadcrumb.tsx new file mode 100644 index 00000000..f19046ac --- /dev/null +++ b/apps/web/src/components/shadcn/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils/client/cn"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>