From 005df5e1869528309e1130185ecb6c7f1dd0fed9 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 9 Oct 2026 23:59:40 +0700 Subject: [PATCH] feat : add theme switcher --- app/globals.css | 44 ++++++++++++++++++- app/layout.tsx | 29 ++++++------ bun.lock | 3 ++ components/admin/admin-header.tsx | 2 + components/admin/overview-image-generator.tsx | 2 +- components/public/constellation-image.tsx | 2 +- components/public/guide-image-export.tsx | 4 +- components/public/material-poster.tsx | 2 +- components/public/site-header.tsx | 2 + components/public/weapon-image-export.tsx | 2 +- components/rich-text-content.tsx | 2 +- components/rich-text-editor.tsx | 2 +- components/theme-provider.tsx | 12 +++++ components/theme-switcher.tsx | 31 +++++++++++++ components/ui/sonner.tsx | 4 +- package.json | 1 + 16 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 components/theme-provider.tsx create mode 100644 components/theme-switcher.tsx diff --git a/app/globals.css b/app/globals.css index 0bc92ed..3fca1ca 100644 --- a/app/globals.css +++ b/app/globals.css @@ -78,7 +78,49 @@ --radius-4xl: calc(var(--radius) * 2.6); } -:root, +:root { + color-scheme: light; + --background: oklch(0.985 0.004 88); + --foreground: oklch(0.22 0.018 275); + --card: oklch(1 0 0); + --card-foreground: var(--foreground); + --popover: oklch(1 0 0); + --popover-foreground: var(--foreground); + --primary: oklch(0.51 0.105 78); + --primary-foreground: oklch(0.99 0.005 88); + --secondary: oklch(0.94 0.01 274); + --secondary-foreground: var(--foreground); + --muted: oklch(0.955 0.008 274); + --muted-foreground: oklch(0.48 0.02 274); + --accent: oklch(0.93 0.015 275); + --accent-foreground: var(--foreground); + --destructive: oklch(0.54 0.2 25); + --success: oklch(0.46 0.12 155); + --warning: oklch(0.51 0.105 78); + --info: oklch(0.47 0.11 235); + --tier-gold: oklch(0.51 0.105 78); + --tier-silver: oklch(0.48 0.02 260); + --tier-copper: oklch(0.49 0.1 55); + --border: oklch(0.22 0.018 275 / 15%); + --input: oklch(0.22 0.018 275 / 22%); + --ring: var(--primary); + --chart-1: oklch(0.51 0.105 78); + --chart-2: oklch(0.5 0.15 255); + --chart-3: oklch(0.46 0.12 155); + --chart-4: oklch(0.5 0.16 325); + --chart-5: oklch(0.47 0.1 210); + --chart-6: oklch(0.54 0.17 25); + --radius: 0.625rem; + --sidebar: oklch(0.965 0.008 274); + --sidebar-foreground: var(--foreground); + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: var(--accent); + --sidebar-accent-foreground: var(--foreground); + --sidebar-border: var(--border); + --sidebar-ring: var(--ring); +} + .dark { color-scheme: dark; --background: oklch(0.13 0.012 275); diff --git a/app/layout.tsx b/app/layout.tsx index ed7338e..4fc51b7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Anuphan, Geist_Mono } from "next/font/google"; import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier"; import { PageProgress } from "@/components/page-progress"; +import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { NotificationProvider } from "@/components/notifications/notification-bell"; import { SitePushPrompt } from "@/components/notifications/site-push-prompt"; @@ -44,22 +45,24 @@ export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - - {children} - - - - + + + + {children} + + + + + ); diff --git a/bun.lock b/bun.lock index 1a5eaae..03bda96 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "lucide-react": "^1.35.0", "modern-screenshot": "^4.7.0", "next": "^16.3.8", + "next-themes": "^0.4.6", "postgres": "^3.4.9", "qrcode": "^1.5.4", "radix-ui": "^1.6.7", @@ -1543,6 +1544,8 @@ "next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "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.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "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-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="], + "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-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="], "node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="], diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 257a16c..53963f3 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -17,6 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { PublicAccountMenu } from "@/components/public/public-account-menu"; import { NotificationBell } from "@/components/notifications/notification-bell"; +import { ThemeSwitcher } from "@/components/theme-switcher"; export function AdminHeader() { const pathname = usePathname(); @@ -104,6 +105,7 @@ export function AdminHeader() { + diff --git a/components/admin/overview-image-generator.tsx b/components/admin/overview-image-generator.tsx index bf41fa8..3401266 100644 --- a/components/admin/overview-image-generator.tsx +++ b/components/admin/overview-image-generator.tsx @@ -279,7 +279,7 @@ function OverviewPoster({
{composition.backgroundUrl ? ( diff --git a/components/public/constellation-image.tsx b/components/public/constellation-image.tsx index 10119d8..01d1d84 100644 --- a/components/public/constellation-image.tsx +++ b/components/public/constellation-image.tsx @@ -35,7 +35,7 @@ export function ConstellationImage({ const footer = group.note; return ( -
+
diff --git a/components/public/guide-image-export.tsx b/components/public/guide-image-export.tsx index 632d50b..122a45c 100644 --- a/components/public/guide-image-export.tsx +++ b/components/public/guide-image-export.tsx @@ -496,7 +496,7 @@ export function GuideImageExport({
@@ -516,7 +516,7 @@ export function GuideImageExport({ ref={captureRef} data-export-language={language} className={cn( - "bg-background p-10 text-foreground", + "dark bg-background p-10 text-foreground", captureClassName, )} style={{ width, height }} diff --git a/components/public/material-poster.tsx b/components/public/material-poster.tsx index a511b23..496a482 100644 --- a/components/public/material-poster.tsx +++ b/components/public/material-poster.tsx @@ -121,7 +121,7 @@ export function MaterialPoster({ return (
+
diff --git a/components/public/weapon-image-export.tsx b/components/public/weapon-image-export.tsx index 814ee12..9f3e80a 100644 --- a/components/public/weapon-image-export.tsx +++ b/components/public/weapon-image-export.tsx @@ -64,7 +64,7 @@ export function WeaponComparisonImage({ const maximum = Math.max(100, ...values); return ( -
+

diff --git a/components/rich-text-content.tsx b/components/rich-text-content.tsx index 20b2671..45f0954 100644 --- a/components/rich-text-content.tsx +++ b/components/rich-text-content.tsx @@ -12,7 +12,7 @@ export function RichTextContent({ return (
p]:m-0", className)} + className={cn("prose prose-sm dark:prose-invert max-w-none leading-snug [&>p]:m-0", className)} dangerouslySetInnerHTML={{ __html: sanitizeRichTextHtml(html) }} /> ); diff --git a/components/rich-text-editor.tsx b/components/rich-text-editor.tsx index 7d1c8bb..bad9634 100644 --- a/components/rich-text-editor.tsx +++ b/components/rich-text-editor.tsx @@ -213,7 +213,7 @@ export function RichTextEditor({ onUpdate: ({ editor: currentEditor }) => onChange(normalizedEditorHtml(currentEditor)), editorProps: { attributes: { - class: "prose prose-sm prose-invert min-h-28 max-w-none px-3 py-2 leading-snug outline-none [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&>p]:m-0", + class: "prose prose-sm dark:prose-invert min-h-28 max-w-none px-3 py-2 leading-snug outline-none [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&>p]:m-0", }, }, }); diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..51485dd --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import type { ReactNode } from "react"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/components/theme-switcher.tsx b/components/theme-switcher.tsx new file mode 100644 index 0000000..d78c0ea --- /dev/null +++ b/components/theme-switcher.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { MoonIcon, SunIcon, SunMoonIcon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useSyncExternalStore } from "react"; + +import { Button } from "@/components/ui/button"; + +const subscribe = () => () => {}; +const clientSnapshot = () => true; +const serverSnapshot = () => false; + +export function ThemeSwitcher() { + const { resolvedTheme, setTheme } = useTheme(); + const mounted = useSyncExternalStore(subscribe, clientSnapshot, serverSnapshot); + const isDark = resolvedTheme === "dark"; + const label = mounted ? (isDark ? "เปลี่ยนเป็นโหมดสว่าง" : "เปลี่ยนเป็นโหมดมืด") : "เปลี่ยนธีม"; + + return ( + + ); +} diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx index d3f848c..ab0b8b9 100644 --- a/components/ui/sonner.tsx +++ b/components/ui/sonner.tsx @@ -1,12 +1,14 @@ "use client" import { Toaster as Sonner, type ToasterProps } from "sonner" +import { useTheme } from "next-themes" import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" const Toaster = ({ ...props }: ToasterProps) => { + const { resolvedTheme } = useTheme() return (