From faf95327f1496ab793cbe824c1e381e3a8666499 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Fri, 21 Aug 2026 10:18:18 +0330 Subject: [PATCH] fix: theme --- app/layout.tsx | 2 +- components/ThemeToggle.tsx | 22 +++++++-- components/icons.tsx | 7 +++ lib/theme.tsx | 94 ++++++++++++++++++++++++++++---------- 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 15e9eb7..4f699a3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -16,7 +16,7 @@ export const metadata: Metadata = { // Runs before paint to set the `.dark` class from saved preference / system, // avoiding a light flash on load. Kept inline + minimal on purpose. -const noFlashTheme = `(function(){try{var t=localStorage.getItem('aram_theme');if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}if(t==='dark'){document.documentElement.classList.add('dark');}}catch(e){}})();`; +const noFlashTheme = `(function(){try{var t=localStorage.getItem('aram_theme');if(t==='dark'||(t!=='light'&&(t='system',window.matchMedia('(prefers-color-scheme: dark)').matches))){document.documentElement.classList.add('dark');}}catch(e){}})();`; export default function RootLayout({ children, diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index 2c23cdf..4b9f315 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -1,21 +1,33 @@ "use client"; import { useTheme } from "@/lib/theme"; -import { SunIcon, MoonIcon } from "./icons"; +import { SunIcon, MoonIcon, MonitorIcon } from "./icons"; + +const LABELS = { + light: "حالت روشن", + dark: "حالت تاریک", + system: "حالت سیستم", +}; + +const ICONS = { + light: SunIcon, + dark: MoonIcon, + system: MonitorIcon, +}; export function ThemeToggle({ className }: { className?: string }) { const { theme, toggle } = useTheme(); - const dark = theme === "dark"; + const Icon = ICONS[theme]; return ( ); } diff --git a/components/icons.tsx b/components/icons.tsx index d4bdb57..b9e0d11 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -203,3 +203,10 @@ export const UserIcon = (p: IconProps) => ( ); + +export const MonitorIcon = (p: IconProps) => ( + + + + +); diff --git a/lib/theme.tsx b/lib/theme.tsx index 2a200cc..3b8ea2f 100644 --- a/lib/theme.tsx +++ b/lib/theme.tsx @@ -1,59 +1,103 @@ "use client"; -// Light/dark theme state. The actual `.dark` class is set on by an inline -// script in the root layout (before paint, to avoid a flash) and kept in sync -// here. Preference persists in localStorage. +// Light / dark / system theme state. The actual `.dark` class is set on +// by an inline script in the root layout (before paint) and kept in sync here. +// Preference persists in localStorage. import { createContext, useCallback, useContext, useEffect, + useRef, useState, } from "react"; export const THEME_STORAGE_KEY = "aram_theme"; -type Theme = "light" | "dark"; +export type Theme = "light" | "dark" | "system"; interface ThemeApi { theme: Theme; + resolved: "light" | "dark"; toggle: () => void; setTheme: (t: Theme) => void; } const ThemeContext = createContext(null); -function applyClass(theme: Theme) { - document.documentElement.classList.toggle("dark", theme === "dark"); +function isSystemDark(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +function applyClass(resolved: "light" | "dark") { + document.documentElement.classList.toggle("dark", resolved === "dark"); +} + +function readStorage(): Theme { + if (typeof window === "undefined") return "system"; + try { + const t = localStorage.getItem(THEME_STORAGE_KEY); + if (t === "light" || t === "dark" || t === "system") return t; + } catch { /* private mode */ } + return "system"; +} + +function resolve(theme: Theme): "light" | "dark" { + return theme === "system" ? (isSystemDark() ? "dark" : "light") : theme; } export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setThemeState] = useState("light"); + const [theme, setThemeState] = useState("system"); + const [resolved, setResolved] = useState<"light" | "dark">("light"); + const mqRef = useRef(null); - // Read whatever the no-flash script already decided. + // Apply theme to and update resolved state. + const apply = useCallback((t: Theme) => { + const r = resolve(t); + applyClass(r); + setResolved(r); + }, []); + + // On mount: read preference, apply, listen to system changes. useEffect(() => { - const isDark = document.documentElement.classList.contains("dark"); - setThemeState(isDark ? "dark" : "light"); - }, []); - - const setTheme = useCallback((t: Theme) => { + const t = readStorage(); setThemeState(t); - applyClass(t); - try { - window.localStorage.setItem(THEME_STORAGE_KEY, t); - } catch { - /* ignore (private mode) */ - } - }, []); + apply(t); + + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + mqRef.current = mq; + + const handler = () => { + // Re-read theme from state (via ref won't work for closures, so re-read localStorage). + const current = readStorage(); + if (current === "system") { + apply("system"); + } + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [apply]); + + const setTheme = useCallback( + (t: Theme) => { + setThemeState(t); + apply(t); + try { + localStorage.setItem(THEME_STORAGE_KEY, t); + } catch { /* private mode */ } + }, + [apply], + ); const toggle = useCallback(() => { - setTheme( - document.documentElement.classList.contains("dark") ? "light" : "dark", - ); - }, [setTheme]); + const order: Theme[] = ["light", "dark", "system"]; + const next = order[(order.indexOf(theme) + 1) % order.length]; + setTheme(next); + }, [theme, setTheme]); return ( - + {children} );