From 1dfa8956dc83fa8c0021a7a8b482260743a85523 Mon Sep 17 00:00:00 2001 From: Collin Barrett <6483057+collinbarrett@users.noreply.github.com> Date: Sat, 14 Jun 2025 09:39:15 -0500 Subject: [PATCH] feat(web): add theme mode toggle --- web/package-lock.json | Bin 308852 -> 335539 bytes web/package.json | 3 + web/src/app/layout.tsx | 14 +- web/src/components/site-header.tsx | 17 ++ web/src/components/theme-mode-toggle.tsx | 40 ++++ web/src/components/theme-provider.tsx | 11 + web/src/components/ui/button.tsx | 59 ++++++ web/src/components/ui/dropdown-menu.tsx | 257 +++++++++++++++++++++++ 8 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 web/src/components/site-header.tsx create mode 100644 web/src/components/theme-mode-toggle.tsx create mode 100644 web/src/components/theme-provider.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/dropdown-menu.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 0eb2a30b855b3f33cd46ad3514b04b616fc1d69c..a675b937c542edc6ef25ac36871fd2d8e64341b5 100644 GIT binary patch delta 14073 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 OO~t^!eLDQ{$NmTRrkphZ delta 111 zcmV-#0FeK)y%hB960iyZlV|~kmpz^V3$q#mV?Be9MYoSd0j*WH_viuMJD1Rw14FkU zB?3DElkhMomtM00F1NWR0_R(|GK2y~M3>-R1Ua|4$O5Smw~yNb2Y|QcTmu5Ux2_rl RZbY}^X9Nk9w<`e!h#e50Ed~Gp diff --git a/web/package.json b/web/package.json index 6d3ad1366..5d41bb77a 100644 --- a/web/package.json +++ b/web/package.json @@ -10,10 +10,13 @@ }, "dependencies": { "@azure/monitor-opentelemetry": "^1.11.1", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.514.0", "next": "15.3.3", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "server-only": "^0.0.1", diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4c358877c..3345a05a6 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { ThemeProvider } from "@/components/theme-provider"; +import { SiteHeader } from "@/components/site-header"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -24,11 +26,19 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + + {children} + ); diff --git a/web/src/components/site-header.tsx b/web/src/components/site-header.tsx new file mode 100644 index 000000000..4cc6711e3 --- /dev/null +++ b/web/src/components/site-header.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ThemeModeToggle } from "./theme-mode-toggle"; + +export function SiteHeader() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/web/src/components/theme-mode-toggle.tsx b/web/src/components/theme-mode-toggle.tsx new file mode 100644 index 000000000..c2aa7a14c --- /dev/null +++ b/web/src/components/theme-mode-toggle.tsx @@ -0,0 +1,40 @@ +"use client"; + +import * as React from "react"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function ThemeModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/web/src/components/theme-provider.tsx b/web/src/components/theme-provider.tsx new file mode 100644 index 000000000..189a2b1a1 --- /dev/null +++ b/web/src/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 000000000..2adaf00da --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/web/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..1fc1f4ee3 --- /dev/null +++ b/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +};