From 23f3c605ec79a9da05944d7336886a8df9ad3cee Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 10 Feb 2025 17:56:36 -0800 Subject: [PATCH] add side bar nav in settings page --- packages/web/package.json | 12 +- packages/web/src/app/globals.css | 16 + .../web/src/app/settings/billing/page.tsx | 8 + .../app/settings/components/sidebar-nav.tsx | 44 + packages/web/src/app/settings/layout.tsx | 74 +- packages/web/src/app/settings/page.tsx | 13 +- .../web/src/components/hooks/use-mobile.tsx | 19 + packages/web/src/components/ui/button.tsx | 2 +- packages/web/src/components/ui/input.tsx | 7 +- packages/web/src/components/ui/sheet.tsx | 140 ++++ packages/web/src/components/ui/sidebar.tsx | 763 ++++++++++++++++++ packages/web/tailwind.config.ts | 148 ++-- yarn.lock | 200 +++-- 13 files changed, 1277 insertions(+), 169 deletions(-) create mode 100644 packages/web/src/app/settings/billing/page.tsx create mode 100644 packages/web/src/app/settings/components/sidebar-nav.tsx create mode 100644 packages/web/src/components/hooks/use-mobile.tsx create mode 100644 packages/web/src/components/ui/sheet.tsx create mode 100644 packages/web/src/components/ui/sidebar.tsx diff --git a/packages/web/package.json b/packages/web/package.json index 106d29f7..82a2a71b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -42,18 +42,18 @@ "@iizukak/codemirror-lang-wgsl": "^0.3.0", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.4", + "@radix-ui/react-tooltip": "^1.1.8", "@replit/codemirror-lang-csharp": "^6.2.0", "@replit/codemirror-lang-nix": "^6.0.1", "@replit/codemirror-lang-solidity": "^6.0.2", @@ -72,7 +72,7 @@ "@viz-js/lang-dot": "^1.0.4", "@xiechao/codemirror-lang-handlebars": "^1.0.4", "ajv": "^8.17.1", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "client-only": "^0.0.1", "clsx": "^2.1.1", "cm6-graphql": "^0.2.0", @@ -98,7 +98,7 @@ "fuse.js": "^7.0.0", "graphql": "^16.9.0", "http-status-codes": "^2.3.0", - "lucide-react": "^0.435.0", + "lucide-react": "^0.475.0", "next": "14.2.21", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.3.0", diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 13502f6d..20dae937 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -30,6 +30,14 @@ --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --highlight: 224, 76%, 48%; + --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 { @@ -58,6 +66,14 @@ --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; --highlight: 217, 91%, 60%; + --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/packages/web/src/app/settings/billing/page.tsx b/packages/web/src/app/settings/billing/page.tsx new file mode 100644 index 00000000..79e6a000 --- /dev/null +++ b/packages/web/src/app/settings/billing/page.tsx @@ -0,0 +1,8 @@ + +export default async function BillingPage() { + return ( +
+

Billing

+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/settings/components/sidebar-nav.tsx b/packages/web/src/app/settings/components/sidebar-nav.tsx new file mode 100644 index 00000000..c8b99744 --- /dev/null +++ b/packages/web/src/app/settings/components/sidebar-nav.tsx @@ -0,0 +1,44 @@ +"use client" + +import Link from "next/link" +import { usePathname } from "next/navigation" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +interface SidebarNavProps extends React.HTMLAttributes { + items: { + href: string + title: string + }[] +} + +export function SidebarNav({ className, items, ...props }: SidebarNavProps) { + const pathname = usePathname() + + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/settings/layout.tsx b/packages/web/src/app/settings/layout.tsx index 2877c918..7195fdc2 100644 --- a/packages/web/src/app/settings/layout.tsx +++ b/packages/web/src/app/settings/layout.tsx @@ -1,17 +1,65 @@ -import { NavigationMenu } from "../components/navigationMenu"; +import { Metadata } from "next" +import Image from "next/image" -export default function Layout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +import { Separator } from "@/components/ui/separator" +import { SidebarNav } from "./components/sidebar-nav" +import { NavigationMenu } from "../components/navigationMenu" - return ( -
- -
-
{children}
-
+export const metadata: Metadata = { + title: "Forms", + description: "Advanced form example using react-hook-form and Zod.", +} + +const sidebarNavItems = [ + { + title: "Members", + href: "/settings", + }, + { + title: "Billing", + href: "/settings/billing", + } +] + +interface SettingsLayoutProps { + children: React.ReactNode +} + +export default function SettingsLayout({ children }: SettingsLayoutProps) { + return ( + <> +
+ Forms + Forms
- ) + +
+
+

Settings

+

+ Manage your organization settings. +

+
+ +
+ +
{children}
+
+
+ + ) } \ No newline at end of file diff --git a/packages/web/src/app/settings/page.tsx b/packages/web/src/app/settings/page.tsx index d912f143..7af9a642 100644 --- a/packages/web/src/app/settings/page.tsx +++ b/packages/web/src/app/settings/page.tsx @@ -6,7 +6,7 @@ import { MemberTable } from "./components/memberTable"; import { MemberInviteForm } from "./components/memberInviteForm"; import { InviteTable } from "./components/inviteTable"; -export default async function SettingsPage() { +export default async function MembersPage() { const fetchData = async () => { const session = await auth(); if (!session) { @@ -67,14 +67,9 @@ export default async function SettingsPage() { return (
-
-

Settings

-
-
- - - -
+ + +
) } \ No newline at end of file diff --git a/packages/web/src/components/hooks/use-mobile.tsx b/packages/web/src/components/hooks/use-mobile.tsx new file mode 100644 index 00000000..2b0fe1df --- /dev/null +++ b/packages/web/src/components/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(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 +} diff --git a/packages/web/src/components/ui/button.tsx b/packages/web/src/components/ui/button.tsx index 0ba42773..36496a28 100644 --- a/packages/web/src/components/ui/button.tsx +++ b/packages/web/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/packages/web/src/components/ui/input.tsx b/packages/web/src/components/ui/input.tsx index 677d05fd..68551b92 100644 --- a/packages/web/src/components/ui/input.tsx +++ b/packages/web/src/components/ui/input.tsx @@ -2,16 +2,13 @@ import * as React from "react" import { cn } from "@/lib/utils" -export interface InputProps - extends React.InputHTMLAttributes {} - -const Input = React.forwardRef( +const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { return ( , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/packages/web/src/components/ui/sidebar.tsx b/packages/web/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..9e5d9163 --- /dev/null +++ b/packages/web/src/components/ui/sidebar.tsx @@ -0,0 +1,763 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/components/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 } from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +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 SidebarContext = { + 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 +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + 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} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +