diff --git a/__tests__/sanity.test.tsx b/__tests__/sanity.test.tsx index 0a8a59c..a450991 100644 --- a/__tests__/sanity.test.tsx +++ b/__tests__/sanity.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import DashboardPage from "@/app/dashboard/page"; +import DashboardPage from "@/app/(authenticated)/dashboard/page"; describe("jest setup", () => { it("runs React + Testing Library", async () => { diff --git a/app/checkout/cancel/page.tsx b/app/(authenticated)/checkout/cancel/page.tsx similarity index 100% rename from app/checkout/cancel/page.tsx rename to app/(authenticated)/checkout/cancel/page.tsx diff --git a/app/checkout/success/page.tsx b/app/(authenticated)/checkout/success/page.tsx similarity index 100% rename from app/checkout/success/page.tsx rename to app/(authenticated)/checkout/success/page.tsx diff --git a/app/dashboard/page.tsx b/app/(authenticated)/dashboard/page.tsx similarity index 100% rename from app/dashboard/page.tsx rename to app/(authenticated)/dashboard/page.tsx diff --git a/app/(authenticated)/finance/page.tsx b/app/(authenticated)/finance/page.tsx new file mode 100644 index 0000000..3aa3ed2 --- /dev/null +++ b/app/(authenticated)/finance/page.tsx @@ -0,0 +1,7 @@ +export default function FinancePage() { + return ( + + Finance + + ); +} diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx new file mode 100644 index 0000000..e4e307b --- /dev/null +++ b/app/(authenticated)/layout.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/utils/supabase/server"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { AppSidebar } from "@/components/app-sidebar"; + +export default async function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + return ( + + + + + + {children} + + + + + ); +} diff --git a/app/(authenticated)/memberships/page.tsx b/app/(authenticated)/memberships/page.tsx new file mode 100644 index 0000000..a681f10 --- /dev/null +++ b/app/(authenticated)/memberships/page.tsx @@ -0,0 +1,7 @@ +export default function MembershipsPage() { + return ( + + Memberships + + ) +} diff --git a/app/page.tsx b/app/(authenticated)/page.tsx similarity index 90% rename from app/page.tsx rename to app/(authenticated)/page.tsx index a8f1fa1..c5ba855 100644 --- a/app/page.tsx +++ b/app/(authenticated)/page.tsx @@ -1,7 +1,6 @@ import { createClient } from "@/utils/supabase/server"; -import { signout } from "./login/actions"; +import { signout } from "@/app/login/actions"; import { getSubscriptionDetails } from "@/lib/stripe"; -import { hasUserPurchased } from "@/lib/purchases"; import { Card, CardContent, @@ -23,17 +22,15 @@ export default async function Page() { if (!user) return null; - const [subscription, ownsProduct] = await Promise.all([ - getSubscriptionDetails(user.id), - hasUserPurchased(user.id, PRODUCT_PRICE_ID), - ]); + const [subscription] = await Promise.all([getSubscriptionDetails(user.id)]); + const ownsProduct = false; return ( - - - + + + - Welcome + Welcome t You are signed in as @@ -46,7 +43,7 @@ export default async function Page() { - + Subscription @@ -107,7 +104,7 @@ export default async function Page() { - + Product diff --git a/app/(authenticated)/services/page.tsx b/app/(authenticated)/services/page.tsx new file mode 100644 index 0000000..ac4b257 --- /dev/null +++ b/app/(authenticated)/services/page.tsx @@ -0,0 +1,7 @@ +export default function ServicesPage() { + return ( + + Services + + ) +} diff --git a/app/(authenticated)/settings/page.tsx b/app/(authenticated)/settings/page.tsx new file mode 100644 index 0000000..8ea74e7 --- /dev/null +++ b/app/(authenticated)/settings/page.tsx @@ -0,0 +1,7 @@ +export default function SettingsPage() { + return ( + + Settings + + ); +} diff --git a/app/(authenticated)/users/page.tsx b/app/(authenticated)/users/page.tsx new file mode 100644 index 0000000..9eefa3c --- /dev/null +++ b/app/(authenticated)/users/page.tsx @@ -0,0 +1,7 @@ +export default function UsersPage() { + return ( + + Users + + ) +} diff --git a/app/globals.css b/app/globals.css index bd3b00e..1fed8df 100644 --- a/app/globals.css +++ b/app/globals.css @@ -7,8 +7,8 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: "Helvetica", "Arial", sans-serif; - --font-mono: ui-monospace, monospace; + --font-sans: Poppins, ui-sans-serif, sans-serif, system-ui; + --font-mono: JetBrains Mono, monospace; --font-heading: "Helvetica", "Arial", sans-serif; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); @@ -46,75 +46,142 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --font-serif: Source Serif 4, serif; + --radius: 0.375rem; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xl: var(--shadow-2xl); + --shadow-xl: var(--shadow-xl); + --shadow-lg: var(--shadow-lg); + --shadow-md: var(--shadow-md); + --shadow: var(--shadow); + --shadow-sm: var(--shadow-sm); + --shadow-xs: var(--shadow-xs); + --shadow-2xs: var(--shadow-2xs); + --spacing: var(--spacing); + --letter-spacing: var(--letter-spacing); + --shadow-offset-y: var(--shadow-offset-y); + --shadow-offset-x: var(--shadow-offset-x); + --shadow-spread: var(--shadow-spread); + --shadow-blur: var(--shadow-blur); + --shadow-opacity: var(--shadow-opacity); + --color-shadow-color: var(--shadow-color); + --color-destructive-foreground: var(--destructive-foreground); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(0.9825 0.0098 87.4700); + --foreground: oklch(0.3211 0 0); + --card: oklch(1.0000 0 0); + --card-foreground: oklch(0.3211 0 0); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0.3211 0 0); + --primary: oklch(0.8426 0.0646 251.4359); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.9125 0.1380 99.6725); + --secondary-foreground: oklch(0.3092 0 0); + --muted: oklch(0.9846 0.0017 247.8389); + --muted-foreground: oklch(0.5510 0.0234 264.3637); + --accent: oklch(0.9514 0.0250 236.8242); + --accent-foreground: oklch(0.3791 0.1378 265.5222); + --destructive: oklch(0.6368 0.2078 25.3313); + --border: oklch(0.9276 0.0058 264.5313); + --input: oklch(0.9276 0.0058 264.5313); + --ring: oklch(0.6231 0.1880 259.8145); + --chart-1: oklch(0.6231 0.1880 259.8145); + --chart-2: oklch(0.5461 0.2152 262.8809); + --chart-3: oklch(0.4882 0.2172 264.3763); + --chart-4: oklch(0.4244 0.1809 265.6377); + --chart-5: oklch(0.3791 0.1378 265.5222); + --radius: 0.375rem; + --sidebar: oklch(0.8426 0.0646 251.4359); + --sidebar-foreground: oklch(0 0 0); + --sidebar-primary: oklch(0.6231 0.1880 259.8145); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.9514 0.0250 236.8242); + --sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222); + --sidebar-border: oklch(0.9276 0.0058 264.5313); + --sidebar-ring: oklch(0.6231 0.1880 259.8145); + --destructive-foreground: oklch(1.0000 0 0); + --font-sans: Poppins, ui-sans-serif, sans-serif, system-ui; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --shadow-color: oklch(0 0 0); + --shadow-opacity: 0.1; + --shadow-blur: 3px; + --shadow-spread: 0px; + --shadow-offset-x: 0; + --shadow-offset-y: 1px; + --letter-spacing: 0em; + --spacing: 0.29rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.2046 0 0); + --foreground: oklch(0.9219 0 0); + --card: oklch(0.2686 0 0); + --card-foreground: oklch(0.9219 0 0); + --popover: oklch(0.2686 0 0); + --popover-foreground: oklch(0.9219 0 0); + --primary: oklch(0.6231 0.1880 259.8145); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.2686 0 0); + --secondary-foreground: oklch(0.9219 0 0); + --muted: oklch(0.2393 0 0); + --muted-foreground: oklch(0.7155 0 0); + --accent: oklch(0.3791 0.1378 265.5222); + --accent-foreground: oklch(0.8823 0.0571 254.1284); + --destructive: oklch(0.6368 0.2078 25.3313); + --border: oklch(0.3715 0 0); + --input: oklch(0.3715 0 0); + --ring: oklch(0.6231 0.1880 259.8145); + --chart-1: oklch(0.7137 0.1434 254.6240); + --chart-2: oklch(0.6231 0.1880 259.8145); + --chart-3: oklch(0.5461 0.2152 262.8809); + --chart-4: oklch(0.4882 0.2172 264.3763); + --chart-5: oklch(0.4244 0.1809 265.6377); + --sidebar: oklch(0.2046 0 0); + --sidebar-foreground: oklch(0.9219 0 0); + --sidebar-primary: oklch(0.6231 0.1880 259.8145); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.3791 0.1378 265.5222); + --sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284); + --sidebar-border: oklch(0.3715 0 0); + --sidebar-ring: oklch(0.6231 0.1880 259.8145); + --destructive-foreground: oklch(1.0000 0 0); + --radius: 0.375rem; + --font-sans: Poppins, ui-sans-serif, sans-serif, system-ui; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --shadow-color: oklch(0 0 0); + --shadow-opacity: 0.1; + --shadow-blur: 3px; + --shadow-spread: 0px; + --shadow-offset-x: 0; + --shadow-offset-y: 1px; + --letter-spacing: 0em; + --spacing: 0.29rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); } @layer base { @@ -123,6 +190,7 @@ } body { @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); } html { @apply font-sans; diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx new file mode 100644 index 0000000..e4f76ea --- /dev/null +++ b/components/app-sidebar.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { + LayoutGrid, + BookOpen, + Users, + CreditCard, + MonitorSmartphone, + Settings, +} from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import Image from "next/image"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { title: "OVERVIEW", href: "/", icon: LayoutGrid }, + { title: "SERVICES", href: "/services", icon: BookOpen }, + { title: "USERS", href: "/users", icon: Users }, + { title: "FINANCE", href: "/finance", icon: CreditCard }, + { title: "MEMBERSHIPS", href: "/memberships", icon: MonitorSmartphone }, +]; + +export function AppSidebar({ + className, + ...props +}: React.ComponentProps) { + const pathname = usePathname(); + + return ( + + + + + + + + + + + + {navItems.map((item) => { + const isActive = pathname === item.href; + return ( + + + + + + {item.title} + + + + + ); + })} + + + + + + + + + + Settings + + + + + + ); +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx index c88ffd6..6138844 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -25,7 +25,7 @@ const buttonVariants = cva( "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", icon: "size-8", "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..d457090 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..8d0e1d3 --- /dev/null +++ b/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) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + 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 +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + 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..d87ef34 --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,718 @@ +"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 = "3.5rem"; +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 */} + + + + {children} + + + + ); +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + { + onClick?.(event); + toggleSidebar(); + }} + {...props} + > + + Toggle Sidebar + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { + return ( + + ); +} + +function SidebarInput({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function SidebarSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function SidebarGroupLabel({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "div"; + + return ( + 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 ( + svg]:size-4 [&>svg]:shrink-0", + className, + )} + {...props} + /> + ); +} + +function SidebarGroupContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ); +} + +function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { + return ( + + ); +} + +function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + + ); +} + +const sidebarMenuButtonVariants = cva( + "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-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_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 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; +} & VariantProps) { + const Comp = asChild ? Slot.Root : "button"; + const { isMobile, state } = useSidebar(); + + const button = ( + + ); + + if (!tooltip) { + return button; + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + }; + } + + return ( + + {button} + + + ); +} + +function SidebarMenuAction({ + className, + asChild = false, + showOnHover = false, + ...props +}: React.ComponentProps<"button"> & { + asChild?: boolean; + showOnHover?: boolean; +}) { + const Comp = asChild ? Slot.Root : "button"; + + return ( + 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 ( + + ); +} + +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 ( + + {showIcon && ( + + )} + + + ); +} + +function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { + return ( + + ); +} + +function SidebarMenuSubItem({ + className, + ...props +}: React.ComponentProps<"li">) { + return ( + + ); +} + +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 ( + 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/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000..0118624 --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { Skeleton } diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..bb1ea52 --- /dev/null +++ b/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) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/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(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/public/logo.png b/public/logo.png new file mode 100644 index 0000000..0017015 Binary files /dev/null and b/public/logo.png differ