From 4d291566145fdce2361ea7dc1629917444f6ee9b Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 13 Aug 2026 02:51:13 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20dark/light=20theme=20?= =?UTF-8?q?toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/bootstrap/page.tsx | 4 +- apps/web/app/globals.css | 72 ++++++++++--------- apps/web/app/layout.tsx | 14 +++- apps/web/app/login/page.tsx | 4 +- apps/web/components/dashboard-layout.tsx | 2 + apps/web/components/theme-provider.tsx | 11 +++ apps/web/components/theme-toggle.tsx | 59 +++++++++++++++ .../components/topology/topology-canvas.tsx | 11 +-- .../topology/topology-primitives.tsx | 8 +-- apps/web/package.json | 1 + bun.lock | 3 + 11 files changed, 144 insertions(+), 45 deletions(-) create mode 100644 apps/web/components/theme-provider.tsx create mode 100644 apps/web/components/theme-toggle.tsx diff --git a/apps/web/app/bootstrap/page.tsx b/apps/web/app/bootstrap/page.tsx index c137484..2ec2a68 100644 --- a/apps/web/app/bootstrap/page.tsx +++ b/apps/web/app/bootstrap/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { AuthFormCard, FormError } from "@/components/auth/auth-form-card"; import { BrandMark } from "@/components/brand"; import { FullScreenLoader } from "@/components/page-layout"; +import { ThemeToggle } from "@/components/theme-toggle"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/lib/api-client"; @@ -82,7 +83,8 @@ export default function BootstrapPage() { if (checkingStatus) return ; return ( -
+
+
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 47f7a28..bc86069 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -89,40 +89,46 @@ --sidebar-accent-foreground: oklch(0.96 0.01 95); --sidebar-border: oklch(0.31 0.025 80); --sidebar-ring: oklch(0.75 0.205 125); + color-scheme: light; } .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.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --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.16 0.018 75); + --foreground: oklch(0.94 0.012 95); + --card: oklch(0.21 0.018 75); + --card-foreground: oklch(0.94 0.012 95); + --popover: oklch(0.19 0.018 75); + --popover-foreground: oklch(0.94 0.012 95); + --primary: oklch(0.75 0.205 125); + --primary-foreground: oklch(0.15 0.025 125); + --secondary: oklch(0.26 0.02 80); + --secondary-foreground: oklch(0.94 0.012 95); + --muted: oklch(0.24 0.016 80); + --muted-foreground: oklch(0.72 0.018 90); + --accent: oklch(0.28 0.04 112); + --accent-foreground: oklch(0.92 0.03 110); + --destructive: oklch(0.68 0.19 28); + --success: oklch(0.72 0.155 132); + --success-foreground: oklch(0.22 0.06 132); + --warning: oklch(0.78 0.14 76); + --warning-foreground: oklch(0.28 0.08 66); + --border: oklch(0.35 0.02 80); + --input: oklch(0.32 0.02 80); + --ring: oklch(0.75 0.205 125); + --chart-1: oklch(0.75 0.205 125); + --chart-2: oklch(0.66 0.16 205); + --chart-3: oklch(0.72 0.17 80); + --chart-4: oklch(0.62 0.16 50); + --chart-5: oklch(0.58 0.14 300); + --sidebar: oklch(0.13 0.018 75); + --sidebar-foreground: oklch(0.91 0.015 95); + --sidebar-primary: oklch(0.75 0.205 125); + --sidebar-primary-foreground: oklch(0.15 0.025 125); + --sidebar-accent: oklch(0.22 0.022 80); + --sidebar-accent-foreground: oklch(0.96 0.01 95); + --sidebar-border: oklch(0.28 0.022 80); + --sidebar-ring: oklch(0.75 0.205 125); + color-scheme: dark; } @layer base { @@ -174,8 +180,8 @@ .auth-grid { background-image: - linear-gradient(to right, color-mix(in oklch, white 9%, transparent) 1px, transparent 1px), - linear-gradient(to bottom, color-mix(in oklch, white 9%, transparent) 1px, transparent 1px); + linear-gradient(to right, color-mix(in oklch, var(--sidebar-foreground) 9%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in oklch, var(--sidebar-foreground) 9%, transparent) 1px, transparent 1px); background-size: 36px 36px; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 6052794..0fb7314 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; export const metadata: Metadata = { @@ -8,8 +9,17 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} + + + + {children} + + ); } diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 0b4297c..8c840fe 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { AuthFormCard, FormError } from "@/components/auth/auth-form-card"; import { BrandLockup } from "@/components/brand"; import { FullScreenLoader } from "@/components/page-layout"; +import { ThemeToggle } from "@/components/theme-toggle"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { authClient, signIn, useSession } from "@/lib/auth-client"; @@ -87,7 +88,8 @@ export default function LoginPage() {

-
+
+ {currentPage ? `${currentPage.context} / ${currentPage.label}` : "Control plane"} +
{children}
diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx new file mode 100644 index 0000000..6459132 --- /dev/null +++ b/apps/web/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import type * as React from "react"; + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/apps/web/components/theme-toggle.tsx b/apps/web/components/theme-toggle.tsx new file mode 100644 index 0000000..dbe00b2 --- /dev/null +++ b/apps/web/components/theme-toggle.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Monitor, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cn"; + +type ThemeToggleProps = { + className?: string; +}; + +export function ThemeToggle({ className }: ThemeToggleProps) { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + + + + + + + + + Light + + + + Dark + + + + System + + + + + ); +} diff --git a/apps/web/components/topology/topology-canvas.tsx b/apps/web/components/topology/topology-canvas.tsx index 95505ad..7f46ab9 100644 --- a/apps/web/components/topology/topology-canvas.tsx +++ b/apps/web/components/topology/topology-canvas.tsx @@ -1,6 +1,7 @@ "use client"; import { Background, BackgroundVariant, Controls, MiniMap, Panel, ReactFlow } from "@xyflow/react"; +import { useTheme } from "next-themes"; import { useCallback, useMemo, useState } from "react"; import "@xyflow/react/dist/style.css"; import { useGraphLayout } from "@/hooks/use-graph-layout"; @@ -15,6 +16,7 @@ interface TopologyCanvasProps { } export function TopologyCanvas({ graph }: TopologyCanvasProps) { + const { resolvedTheme } = useTheme(); const [selectedNode, setSelectedNode] = useState(null); const [filters, setFilters] = useState({ showServers: true, @@ -68,24 +70,25 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) { }} minZoom={0.1} maxZoom={1.5} + colorMode={resolvedTheme === "dark" ? "dark" : "light"} defaultEdgeOptions={{ animated: false, type: "smoothstep", style: { - stroke: "#6f7565", + stroke: "var(--muted-foreground)", strokeWidth: 2, strokeDasharray: "5 5", }, markerEnd: { type: "arrowclosed", - color: "#6f7565", + color: "var(--muted-foreground)", width: 20, height: 20, }, }} proOptions={{ hideAttribution: true }} > - + diff --git a/apps/web/components/topology/topology-primitives.tsx b/apps/web/components/topology/topology-primitives.tsx index 02b7aa2..8a914c5 100644 --- a/apps/web/components/topology/topology-primitives.tsx +++ b/apps/web/components/topology/topology-primitives.tsx @@ -42,7 +42,7 @@ export function HealthBadge({ healthy: "bg-green-500", degraded: "bg-yellow-500", unhealthy: "bg-red-500", - unknown: "bg-gray-400", + unknown: "bg-muted-foreground", }[status]; return ( @@ -100,10 +100,10 @@ export function TopologyNodeCard({
- +
@@ -119,7 +119,7 @@ export function TopologyNodeCard({
{children}
- +
); } diff --git a/apps/web/package.json b/apps/web/package.json index ebb83c3..686482b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "elysia": "^1.4.29", "lucide-react": "^1.31.0", "next": "^16.3.0", + "next-themes": "^0.4.6", "postcss": "^8.5.26", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/bun.lock b/bun.lock index 0324ba5..1de0822 100644 --- a/bun.lock +++ b/bun.lock @@ -81,6 +81,7 @@ "elysia": "^1.4.29", "lucide-react": "^1.31.0", "next": "^16.3.0", + "next-themes": "^0.4.6", "postcss": "^8.5.26", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -1103,6 +1104,8 @@ "next": ["next@16.3.0", "", { "dependencies": { "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0", "@next/swc-darwin-x64": "16.3.0", "@next/swc-linux-arm64-gnu": "16.3.0", "@next/swc-linux-arm64-musl": "16.3.0", "@next/swc-linux-x64-gnu": "16.3.0", "@next/swc-linux-x64-musl": "16.3.0", "@next/swc-win32-arm64-msvc": "16.3.0", "@next/swc-win32-x64-msvc": "16.3.0", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],