diff --git a/package-lock.json b/package-lock.json index 1a3a7f5..4c9f377 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.503.0", "next": "15.3.1", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.79.0", @@ -1376,7 +1377,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1386,7 +1387,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2376,7 +2377,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -3318,6 +3319,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4649,6 +4651,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5077,7 +5089,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, diff --git a/package.json b/package.json index 8cac8e0..c7bf40f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.503.0", "next": "15.3.1", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.79.0", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8e56bf5..0c06ef8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import ThemeProvider from "@/components/theme/ThemeProvider"; import { QueryProvider } from "@/lib/query/client"; import "./globals.css"; @@ -24,11 +25,13 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + {children} + ); diff --git a/src/components/navigation/TopNav.tsx b/src/components/navigation/TopNav.tsx index 1d55e32..cc8c7eb 100644 --- a/src/components/navigation/TopNav.tsx +++ b/src/components/navigation/TopNav.tsx @@ -2,6 +2,7 @@ import { Bell, Menu, UserCircle } from "lucide-react" import { Button } from "@/components/ui/button" +import ThemeToggle from "@/components/theme/ThemeToggle" interface TopNavProps { onOpenSidebar: () => void @@ -37,6 +38,7 @@ export default function TopNav({ onOpenSidebar }: TopNavProps) { + diff --git a/src/components/theme/ThemeProvider.tsx b/src/components/theme/ThemeProvider.tsx new file mode 100644 index 0000000..28792e8 --- /dev/null +++ b/src/components/theme/ThemeProvider.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export default function ThemeProvider({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/theme/ThemeToggle.tsx b/src/components/theme/ThemeToggle.tsx new file mode 100644 index 0000000..5655f3b --- /dev/null +++ b/src/components/theme/ThemeToggle.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { Monitor, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const themeOptions = [ + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, + { value: "system", label: "System", icon: Monitor }, +] as const; + +export default function ThemeToggle() { + const { theme, setTheme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(false); + const menuId = useId(); + const containerRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!open) return; + + function handlePointerDown(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + } + } + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + const displayIcon = + resolvedTheme === "dark" ? ( +