From f4285f11bc08cf9a29b6ab62b9a8f9fe895f724e Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Tue, 30 Jun 2026 21:02:22 +0000 Subject: [PATCH] feat(frontend): scaffold dashboard --- frontend/app/layout.tsx | 18 +- frontend/app/page.tsx | 8 +- frontend/components/dashboard.tsx | 171 ++++++ frontend/components/pages/batches.tsx | 3 + frontend/components/pages/customers.tsx | 3 + frontend/components/pages/flavors.tsx | 3 + frontend/components/pages/ingredients.tsx | 3 + frontend/components/pages/orders.tsx | 3 + frontend/components/pages/suppliers.tsx | 3 + frontend/components/pages/users.tsx | 3 + frontend/components/ui/avatar.tsx | 112 ++++ frontend/components/ui/badge.tsx | 49 ++ frontend/components/ui/sheet.tsx | 147 +++++ frontend/components/ui/sidebar.tsx | 705 ++++++++++++++++++++++ frontend/components/ui/skeleton.tsx | 13 + frontend/components/ui/tooltip.tsx | 57 ++ frontend/hooks/use-mobile.ts | 19 + 17 files changed, 1309 insertions(+), 11 deletions(-) create mode 100644 frontend/components/dashboard.tsx create mode 100644 frontend/components/pages/batches.tsx create mode 100644 frontend/components/pages/customers.tsx create mode 100644 frontend/components/pages/flavors.tsx create mode 100644 frontend/components/pages/ingredients.tsx create mode 100644 frontend/components/pages/orders.tsx create mode 100644 frontend/components/pages/suppliers.tsx create mode 100644 frontend/components/pages/users.tsx create mode 100644 frontend/components/ui/avatar.tsx create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/sheet.tsx create mode 100644 frontend/components/ui/sidebar.tsx create mode 100644 frontend/components/ui/skeleton.tsx create mode 100644 frontend/components/ui/tooltip.tsx create mode 100644 frontend/hooks/use-mobile.ts diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index a4c7321..1e47bc6 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,10 +1,11 @@ -import { Geist, Geist_Mono, Inter } from "next/font/google" +import { Geist_Mono, Inter } from "next/font/google" import "./globals.css" import { ThemeProvider } from "@/components/theme-provider" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" +import { TooltipProvider } from "@/components/ui/tooltip" -const inter = Inter({subsets:['latin'],variable:'--font-sans'}) +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }) const fontMono = Geist_Mono({ subsets: ["latin"], @@ -20,10 +21,17 @@ export default function RootLayout({ - {children} + + {children} + ) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 925b86d..1d82903 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,14 +1,10 @@ import { redirect } from "next/navigation" import { getSession } from "@/lib/session" +import { Dashboard } from "@/components/dashboard" export default async function DashboardPage() { const user = await getSession() if (!user) redirect("/login") - return ( -
-

Dashboard

-

Welcome, {user.full_name}

-
- ) + return } diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx new file mode 100644 index 0000000..4956445 --- /dev/null +++ b/frontend/components/dashboard.tsx @@ -0,0 +1,171 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { User } from "@/lib/session" + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { Separator } from "@/components/ui/separator" +import { Button } from "@/components/ui/button" +import { + LayoutDashboard, + Users, + Candy, + FlaskConical, + ShoppingCart, + Truck, + UserCog, + LogOut, + ChefHat, +} from "lucide-react" + +import { BatchesPage } from "@/components/pages/batches" +import { CustomersPage } from "@/components/pages/customers" +import { FlavorsPage } from "@/components/pages/flavors" +import { IngredientsPage } from "@/components/pages/ingredients" +import { OrdersPage } from "@/components/pages/orders" +import { SuppliersPage } from "@/components/pages/suppliers" +import { UsersPage } from "@/components/pages/users" + +type Page = + | "batches" + | "customers" + | "flavors" + | "ingredients" + | "orders" + | "suppliers" + | "users" + +const NAV_ITEMS: { label: string; page: Page; icon: React.ElementType }[] = [ + { label: "Batches", page: "batches", icon: Candy }, + { label: "Customers", page: "customers", icon: Users }, + { label: "Flavors", page: "flavors", icon: ChefHat }, + { label: "Ingredients", page: "ingredients", icon: FlaskConical }, + { label: "Orders", page: "orders", icon: ShoppingCart }, + { label: "Suppliers", page: "suppliers", icon: Truck }, + { label: "Users", page: "users", icon: UserCog }, +] + +const PAGE_MAP: Record = { + batches: , + customers: , + flavors: , + ingredients: , + orders: , + suppliers: , + users: , +} + +interface DashboardProps { + user: User +} + +export function Dashboard({ user }: DashboardProps) { + const [activePage, setActivePage] = useState("batches") + const router = useRouter() + + const initials = user.full_name + ? user.full_name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + : (user.full_name?.[0].toUpperCase() ?? "?") + + async function handleLogout() { + await fetch("/api/auth/logout", { method: "POST" }) + router.push("/login") + router.refresh() + } + + return ( + + + +
+ + Hope's Caramels +
+
+ + + + + + Management + + + {NAV_ITEMS.map(({ label, page, icon: Icon }) => ( + + setActivePage(page)} + > + + {label} + + + ))} + + + + + + + +
+ + {initials} + +
+ {user.full_name && ( + + {user.full_name} + + )} + {user.username && ( + + {user.username} + + )} +
+
+ +
+
+ + +
+ + +

{activePage}

+
+ +
{PAGE_MAP[activePage]}
+
+
+ ) +} diff --git a/frontend/components/pages/batches.tsx b/frontend/components/pages/batches.tsx new file mode 100644 index 0000000..9531b1d --- /dev/null +++ b/frontend/components/pages/batches.tsx @@ -0,0 +1,3 @@ +export function BatchesPage() { + return +} diff --git a/frontend/components/pages/customers.tsx b/frontend/components/pages/customers.tsx new file mode 100644 index 0000000..38ce26f --- /dev/null +++ b/frontend/components/pages/customers.tsx @@ -0,0 +1,3 @@ +export function CustomersPage() { + return <title title="Customers" /> +} diff --git a/frontend/components/pages/flavors.tsx b/frontend/components/pages/flavors.tsx new file mode 100644 index 0000000..23f0e69 --- /dev/null +++ b/frontend/components/pages/flavors.tsx @@ -0,0 +1,3 @@ +export function FlavorsPage() { + return <title title="Flavors" /> +} diff --git a/frontend/components/pages/ingredients.tsx b/frontend/components/pages/ingredients.tsx new file mode 100644 index 0000000..408b3fe --- /dev/null +++ b/frontend/components/pages/ingredients.tsx @@ -0,0 +1,3 @@ +export function IngredientsPage() { + return <title title="Ingredients & types" /> +} diff --git a/frontend/components/pages/orders.tsx b/frontend/components/pages/orders.tsx new file mode 100644 index 0000000..dd1bddb --- /dev/null +++ b/frontend/components/pages/orders.tsx @@ -0,0 +1,3 @@ +export function OrdersPage() { + return <title title="Orders" /> +} diff --git a/frontend/components/pages/suppliers.tsx b/frontend/components/pages/suppliers.tsx new file mode 100644 index 0000000..badaa4c --- /dev/null +++ b/frontend/components/pages/suppliers.tsx @@ -0,0 +1,3 @@ +export function SuppliersPage() { + return <title title="Suppliers" /> +} diff --git a/frontend/components/pages/users.tsx b/frontend/components/pages/users.tsx new file mode 100644 index 0000000..1227e9b --- /dev/null +++ b/frontend/components/pages/users.tsx @@ -0,0 +1,3 @@ +export function UsersPage() { + return <title title="Users" /> +} diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx new file mode 100644 index 0000000..47ff2e2 --- /dev/null +++ b/frontend/components/ui/avatar.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Root> & { + size?: "default" | "sm" | "lg" +}) { + return ( + <AvatarPrimitive.Root + data-slot="avatar" + data-size={size} + className={cn( + "group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", + className + )} + {...props} + /> + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Image>) { + return ( + <AvatarPrimitive.Image + data-slot="avatar-image" + className={cn( + "aspect-square size-full rounded-full object-cover", + className + )} + {...props} + /> + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { + return ( + <AvatarPrimitive.Fallback + data-slot="avatar-fallback" + className={cn( + "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs", + className + )} + {...props} + /> + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + <span + data-slot="avatar-badge" + className={cn( + "absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none", + "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="avatar-group" + className={cn( + "group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", + className + )} + {...props} + /> + ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="avatar-group-count" + className={cn( + "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..0b21a2b --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps<typeof badgeVariants> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + <Comp + data-slot="badge" + data-variant={variant} + className={cn(badgeVariants({ variant }), className)} + {...props} + /> + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/components/ui/sheet.tsx b/frontend/components/ui/sheet.tsx new file mode 100644 index 0000000..eb2c8a0 --- /dev/null +++ b/frontend/components/ui/sheet.tsx @@ -0,0 +1,147 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { + return <SheetPrimitive.Root data-slot="sheet" {...props} /> +} + +function SheetTrigger({ + ...props +}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { + return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> +} + +function SheetClose({ + ...props +}: React.ComponentProps<typeof SheetPrimitive.Close>) { + return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> +} + +function SheetPortal({ + ...props +}: React.ComponentProps<typeof SheetPrimitive.Portal>) { + return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps<typeof SheetPrimitive.Overlay>) { + return ( + <SheetPrimitive.Overlay + data-slot="sheet-overlay" + className={cn( + "fixed inset-0 z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", + className + )} + {...props} + /> + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps<typeof SheetPrimitive.Content> & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + data-slot="sheet-content" + data-side={side} + className={cn( + "fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", + className + )} + {...props} + > + {children} + {showCloseButton && ( + <SheetPrimitive.Close data-slot="sheet-close" asChild> + <Button + variant="ghost" + className="absolute top-4 right-4 bg-secondary" + size="icon-sm" + > + <XIcon + /> + <span className="sr-only">Close</span> + </Button> + </SheetPrimitive.Close> + )} + </SheetPrimitive.Content> + </SheetPortal> + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sheet-header" + className={cn("flex flex-col gap-1.5 p-6", className)} + {...props} + /> + ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sheet-footer" + className={cn("mt-auto flex flex-col gap-2 p-6", className)} + {...props} + /> + ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps<typeof SheetPrimitive.Title>) { + return ( + <SheetPrimitive.Title + data-slot="sheet-title" + className={cn( + "font-heading text-base font-medium text-foreground", + className + )} + {...props} + /> + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps<typeof SheetPrimitive.Description>) { + return ( + <SheetPrimitive.Description + data-slot="sheet-description" + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/frontend/components/ui/sidebar.tsx b/frontend/components/ui/sidebar.tsx new file mode 100644 index 0000000..d1c6415 --- /dev/null +++ b/frontend/components/ui/sidebar.tsx @@ -0,0 +1,705 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext<SidebarContextProps | null>(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo<SidebarContextProps>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + <SidebarContext.Provider value={contextValue}> + <div + data-slot="sidebar-wrapper" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as React.CSSProperties + } + className={cn( + "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", + className + )} + {...props} + > + {children} + </div> + </SidebarContext.Provider> + ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( + <div + data-slot="sidebar" + className={cn( + "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", + className + )} + {...props} + > + {children} + </div> + ) + } + + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + dir={dir} + data-sidebar="sidebar" + data-slot="sidebar" + data-mobile="true" + className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + } as React.CSSProperties + } + side={side} + > + <SheetHeader className="sr-only"> + <SheetTitle>Sidebar</SheetTitle> + <SheetDescription>Displays the mobile sidebar.</SheetDescription> + </SheetHeader> + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ) + } + + return ( + <div + className="group peer hidden text-sidebar-foreground md:block" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side} + data-slot="sidebar" + > + {/* This is what handles the sidebar gap on desktop */} + <div + data-slot="sidebar-gap" + className={cn( + "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" + )} + /> + <div + data-slot="sidebar-container" + data-side={side} + className={cn( + "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex", + // Adjust the padding for floating and inset variants. + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", + className + )} + {...props} + > + <div + data-sidebar="sidebar" + data-slot="sidebar-inner" + className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border" + > + {children} + </div> + </div> + </div> + ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps<typeof Button>) { + const { toggleSidebar } = useSidebar() + + return ( + <Button + data-sidebar="trigger" + data-slot="sidebar-trigger" + variant="ghost" + size="icon-sm" + className={cn(className)} + onClick={(event) => { + onClick?.(event) + toggleSidebar() + }} + {...props} + > + <PanelLeftIcon /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + <button + data-sidebar="rail" + data-slot="sidebar-rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2", + "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className + )} + {...props} + /> + ) +} + +function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { + return ( + <main + data-slot="sidebar-inset" + className={cn( + "relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", + className + )} + {...props} + /> + ) +} + +function SidebarInput({ + className, + ...props +}: React.ComponentProps<typeof Input>) { + return ( + <Input + data-slot="sidebar-input" + data-sidebar="input" + className={cn("h-8 w-full bg-input/50 shadow-none", className)} + {...props} + /> + ) +} + +function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-header" + data-sidebar="header" + className={cn( + "flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]", + className + )} + {...props} + /> + ) +} + +function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-footer" + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ) +} + +function SidebarSeparator({ + className, + ...props +}: React.ComponentProps<typeof Separator>) { + return ( + <Separator + data-slot="sidebar-separator" + data-sidebar="separator" + className={cn("mx-2 w-auto bg-sidebar-border", className)} + {...props} + /> + ) +} + +function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-content" + data-sidebar="content" + className={cn( + "no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden", + className + )} + {...props} + /> + ) +} + +function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-group" + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} + /> + ) +} + +function SidebarGroupLabel({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "div" + + return ( + <Comp + data-slot="sidebar-group-label" + data-sidebar="group-label" + className={cn( + "flex h-8 shrink-0 items-center rounded-xl px-3 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + className + )} + {...props} + /> + ) +} + +function SidebarGroupAction({ + className, + asChild = false, + ...props +}: React.ComponentProps<"button"> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + <Comp + data-slot="sidebar-group-action" + data-sidebar="group-action" + className={cn( + "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", + className + )} + {...props} + /> + ) +} + +function SidebarGroupContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-group-content" + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} + /> + ) +} + +function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { + return ( + <ul + data-slot="sidebar-menu" + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-0.5", className)} + {...props} + /> + ) +} + +function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + <li + data-slot="sidebar-menu-item" + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} + /> + ) +} + +const sidebarMenuButtonVariants = cva( + "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]", + }, + size: { + default: "h-9 text-sm", + sm: "h-8 text-xs", + lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function SidebarMenuButton({ + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props +}: React.ComponentProps<"button"> & { + asChild?: boolean + isActive?: boolean + tooltip?: string | React.ComponentProps<typeof TooltipContent> +} & VariantProps<typeof sidebarMenuButtonVariants>) { + const Comp = asChild ? Slot.Root : "button" + const { isMobile, state } = useSidebar() + + const button = ( + <Comp + data-slot="sidebar-menu-button" + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ) + + if (!tooltip) { + return button + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + } + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} + /> + </Tooltip> + ) +} + +function SidebarMenuAction({ + className, + asChild = false, + showOnHover = false, + ...props +}: React.ComponentProps<"button"> & { + asChild?: boolean + showOnHover?: boolean +}) { + const Comp = asChild ? Slot.Root : "button" + + return ( + <Comp + data-slot="sidebar-menu-action" + data-sidebar="menu-action" + className={cn( + "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0", + className + )} + {...props} + /> + ) +} + +function SidebarMenuBadge({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="sidebar-menu-badge" + data-sidebar="menu-badge" + className={cn( + "pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-xl px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground", + className + )} + {...props} + /> + ) +} + +function SidebarMenuSkeleton({ + className, + showIcon = false, + ...props +}: React.ComponentProps<"div"> & { + showIcon?: boolean +}) { + // Random width between 50 to 90%. + const [width] = React.useState(() => { + return `${Math.floor(Math.random() * 40) + 50}%` + }) + + return ( + <div + data-slot="sidebar-menu-skeleton" + data-sidebar="menu-skeleton" + className={cn("flex h-8 items-center gap-2 rounded-xl px-2", className)} + {...props} + > + {showIcon && ( + <Skeleton + className="size-4 rounded-xl" + data-sidebar="menu-skeleton-icon" + /> + )} + <Skeleton + className="h-4 max-w-(--skeleton-width) flex-1" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width, + } as React.CSSProperties + } + /> + </div> + ) +} + +function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { + return ( + <ul + data-slot="sidebar-menu-sub" + data-sidebar="menu-sub" + className={cn( + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ) +} + +function SidebarMenuSubItem({ + className, + ...props +}: React.ComponentProps<"li">) { + return ( + <li + data-slot="sidebar-menu-sub-item" + data-sidebar="menu-sub-item" + className={cn("group/menu-sub-item relative", className)} + {...props} + /> + ) +} + +function SidebarMenuSubButton({ + asChild = false, + size = "md", + isActive = false, + className, + ...props +}: React.ComponentProps<"a"> & { + asChild?: boolean + size?: "sm" | "md" + isActive?: boolean +}) { + const Comp = asChild ? Slot.Root : "a" + + return ( + <Comp + data-slot="sidebar-menu-sub-button" + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-xl px-3 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + className + )} + {...props} + /> + ) +} + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx new file mode 100644 index 0000000..d598583 --- /dev/null +++ b/frontend/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="skeleton" + className={cn("animate-pulse rounded-2xl bg-muted", className)} + {...props} + /> + ) +} + +export { Skeleton } diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx new file mode 100644 index 0000000..e4e739e --- /dev/null +++ b/frontend/components/ui/tooltip.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { + return ( + <TooltipPrimitive.Provider + data-slot="tooltip-provider" + delayDuration={delayDuration} + {...props} + /> + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Root>) { + return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { + return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Content>) { + return ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + data-slot="tooltip-content" + sideOffset={sideOffset} + className={cn( + "z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-xl bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + className + )} + {...props} + > + {children} + <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px]" /> + </TooltipPrimitive.Content> + </TooltipPrimitive.Portal> + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/frontend/hooks/use-mobile.ts b/frontend/hooks/use-mobile.ts new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/frontend/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} -- 2.51.2