diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 78c41ab..73a140f 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -2,11 +2,13 @@ import { verifySession } from "@/lib/supabase/dal" import { redirect } from "next/navigation"; const AdminDashboard = async () => { - const { isAdmin } = await verifySession(); - if (!isAdmin) redirect("/client/dashboard"); - + const session = await verifySession(); + if (!session.isAdmin) redirect("/client/dashboard"); + return ( -
AdminDashboard
+
+

{session.email}

+
) } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..5221208 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,21 @@ +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { verifySession } from "@/lib/supabase/dal"; +import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; + + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await verifySession() + + + return ( + + +
+
+ +
+
{children}
+
+
+ ) +} \ No newline at end of file diff --git a/app/client/layout.tsx b/app/client/layout.tsx new file mode 100644 index 0000000..2ef6f50 --- /dev/null +++ b/app/client/layout.tsx @@ -0,0 +1,21 @@ +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { verifySession } from "@/lib/supabase/dal"; +import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; + + +export default async function ClientLayout({ children }: { children: React.ReactNode }) { + const session = await verifySession() + + + return ( + + +
+
+ +
+
{children}
+
+
+ ) +} \ No newline at end of file diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..3afe8a9 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" +import { cn } from "cn" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + 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 ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
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/components/ui/badge.tsx b/components/ui/badge.tsx index 6e8404c..7e69634 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" const badgeVariants = cva( - "inline-flex w-fit shrink-0 items-center justify-center rounded-full px-2.5 py-0.5 text-label uppercase tracking-[0.02em] whitespace-nowrap", + "inline-flex w-fit shrink-0 items-center justify-center rounded-full px-2.5 py-0.5 text-label whitespace-nowrap", { variants: { variant: { diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 87ea907..fdc1736 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -21,6 +21,7 @@ const buttonVariants = cva( md: "h-10 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", lg: "h-12 gap-2 px-4 text-base has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", icon: "size-10", + "icon-sm": "size-8", }, }, defaultVariants: { diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..57c0b67 --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" +import { cn } from "cn" + +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..9d81c61 --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,723 @@ +"use client" + +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +import { useIsMobile } from "@/hooks/use-mobile" +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(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( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +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 ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + + + ) +} diff --git a/modules/shared/ui/components/sidebar-menu-link.tsx b/modules/shared/ui/components/sidebar-menu-link.tsx new file mode 100644 index 0000000..54e0be6 --- /dev/null +++ b/modules/shared/ui/components/sidebar-menu-link.tsx @@ -0,0 +1,31 @@ +"use client" + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; + +export function SidebarMenuLink({ + title, + url, + icon, +}: { + title: string; + url: string; + icon: React.ReactNode; +}) { + const pathname = usePathname(); + const isActive = pathname === url; + + return ( + + } + isActive={isActive} + className="h-10 text-[0.95rem]" + > + {icon} + {title} + + + ) +} diff --git a/modules/shared/ui/components/user-badge.tsx b/modules/shared/ui/components/user-badge.tsx new file mode 100644 index 0000000..8a185ee --- /dev/null +++ b/modules/shared/ui/components/user-badge.tsx @@ -0,0 +1,12 @@ +import { Badge } from "@/components/ui/badge" +import { Session } from "../../types/user" + +const UserBadge = ({session}: {session: Session}) => { + return ( + + {session.isAdmin ? "Admin" : "Kunde"} + + ) +} + +export default UserBadge \ No newline at end of file