diff --git a/bun.lockb b/bun.lockb index adc0e66..7b48241 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components.json b/components.json index 8a44063..c73c0dd 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "tailwind.config.ts", - "css": "src/app/globals.css", + "css": "src/styles/globals.css", "baseColor": "slate", "cssVariables": true, "prefix": "" diff --git a/package.json b/package.json index 75def4a..85f97c5 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,17 @@ "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.0.1", "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", "@scalar/nextjs-api-reference": "^0.4.105", "@tanstack/react-query": "^5.60.5", "@types/jest": "^29.5.14", "@types/jstoxml": "^2.0.4", "better-auth": "^1.0.18", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^16.4.5", "dotenv-expand": "^12.0.1", diff --git a/src/app/(dashboard)/dashboard/[route]/page.tsx b/src/app/(dashboard)/dashboard/[route]/page.tsx new file mode 100644 index 0000000..6558abe --- /dev/null +++ b/src/app/(dashboard)/dashboard/[route]/page.tsx @@ -0,0 +1,15 @@ +import RoutePage from './route-page'; + +export default async function page({ + params, +}: { + params: Promise<{ route: string }>; +}) { + const { route } = await params; + + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/[route]/route-page.tsx b/src/app/(dashboard)/dashboard/[route]/route-page.tsx new file mode 100644 index 0000000..78ed070 --- /dev/null +++ b/src/app/(dashboard)/dashboard/[route]/route-page.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +export default function RoutePage({ route }: { route: string }) { + const { data, isFetching } = useQuery({ + queryKey: ['route', route], + queryFn: async () => { + const response = await fetch(`/api/v1/${route}`); + return response.json(); + }, + refetchOnWindowFocus: false, + }); + return ( +
+ {isFetching + ? ( +

Loading...

+ ) + : ( +
{JSON.stringify(data, null, 2)}
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/layout.tsx b/src/app/(dashboard)/dashboard/layout.tsx new file mode 100644 index 0000000..353ac5d --- /dev/null +++ b/src/app/(dashboard)/dashboard/layout.tsx @@ -0,0 +1,34 @@ +'use client'; +import { usePathname } from 'next/navigation'; + +import { AppSidebar } from '@/components/app-sidebar'; +import { Separator } from '@/components/ui/separator'; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from '@/components/ui/sidebar'; + +export default function Page({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + let route = pathname.split('/').pop(); + if (route) { + route = route + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + return ( + + + +
+ + + {route} +
+ {children} +
+
+ ); +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..c8fe673 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref, + ) => ( + + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..e45fcc0 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + 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; + +function SheetHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} +SheetHeader.displayName = 'SheetHeader'; + +function SheetFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} +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, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetOverlay, + SheetPortal, + SheetTitle, + SheetTrigger, +}; diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..7409697 --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,775 @@ +'use client'; + +import type { VariantProps } from 'class-variance-authority'; + +import { Slot } from '@radix-ui/react-slot'; +import { cva } from 'class-variance-authority'; +import { PanelLeft } from 'lucide-react'; +import * as React from 'react'; + +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'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; + +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'; + +interface SidebarContext { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +} + +// eslint-disable-next-line ts/no-redeclare +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 ( +