feat: refactor theme management and remove next-themes dependency
Docker images / backend-image (push) Successful in 2m35s
Docker images / frontend-image (push) Successful in 2m9s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped

- Replaced the next-themes package with a custom ThemeProvider component for enhanced theme management.
- Implemented local storage handling for theme persistence and system theme detection.
- Updated the Toaster component to utilize the new theme context.
- Removed unused next-themes references from package.json and package-lock.json.
This commit is contained in:
Denozordec
2026-05-12 16:07:40 +07:00
parent bb07ba0f69
commit 40e2040fb0
5 changed files with 159 additions and 50 deletions
+2 -2
View File
@@ -209,10 +209,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
title: it.title,
url: it.url,
icon: it.icon,
badge: badgeFor(it.url),
badge: mounted ? badgeFor(it.url) : undefined,
})),
}))
}, [mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
}, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
const isDark = (resolvedTheme ?? "dark") === "dark"
+155 -20
View File
@@ -1,25 +1,160 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ComponentProps } from "react"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import { useServerInsertedHTML } from "next/navigation"
/** Обёртка над next-themes: тот же storageKey `rl-theme`, класс на `<html>`. */
export function ThemeProvider({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem
storageKey="rl-theme"
disableTransitionOnChange
{...props}
>
{children}
</NextThemesProvider>
)
const STORAGE_KEY = "rl-theme"
const MEDIA_QUERY = "(prefers-color-scheme: dark)"
const THEMES = ["light", "dark", "system"] as const
type Theme = (typeof THEMES)[number]
type ResolvedTheme = "light" | "dark"
const THEME_INIT_SCRIPT = `(function(){try{var k="${STORAGE_KEY}",d="dark",e=document.documentElement,t=localStorage.getItem(k)||d,s=function(){return window.matchMedia("${MEDIA_QUERY}").matches?"dark":"light"}();if(t==="system")t=s;e.classList.remove("light","dark");e.classList.add(t);e.style.colorScheme=t}catch(n){}})()`
type ThemeContextValue = {
theme: Theme | undefined
setTheme: (theme: Theme | ((prev: Theme) => Theme)) => void
resolvedTheme: ResolvedTheme | undefined
systemTheme: ResolvedTheme | undefined
themes: readonly Theme[]
}
export { useTheme } from "next-themes"
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
type ThemeProviderProps = {
children: ReactNode
defaultTheme?: Theme
enableSystem?: boolean
disableTransitionOnChange?: boolean
}
function readStoredTheme(defaultTheme: Theme, enableSystem: boolean): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === "light" || stored === "dark") return stored
if (enableSystem && stored === "system") return stored
} catch {
// ignore
}
return defaultTheme
}
function resolveTheme(theme: Theme, systemTheme: ResolvedTheme): ResolvedTheme {
return theme === "system" ? systemTheme : theme
}
function applyThemeClass(resolved: ResolvedTheme, disableTransitionOnChange: boolean) {
const root = document.documentElement
if (disableTransitionOnChange) {
const style = document.createElement("style")
style.appendChild(
document.createTextNode(
"*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}",
),
)
document.head.appendChild(style)
window.getComputedStyle(document.body)
setTimeout(() => {
document.head.removeChild(style)
}, 1)
}
root.classList.remove("light", "dark")
root.classList.add(resolved)
root.style.colorScheme = resolved
}
export function ThemeProvider({
children,
defaultTheme = "dark",
enableSystem = true,
disableTransitionOnChange = false,
}: ThemeProviderProps) {
useServerInsertedHTML(() => (
<script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
))
const [theme, setThemeState] = useState<Theme>(defaultTheme)
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>("dark")
const [ready, setReady] = useState(false)
useEffect(() => {
const media = window.matchMedia(MEDIA_QUERY)
const onSystemChange = (event: MediaQueryListEvent) => {
setSystemTheme(event.matches ? "dark" : "light")
}
setSystemTheme(media.matches ? "dark" : "light")
media.addEventListener("change", onSystemChange)
const stored = readStoredTheme(defaultTheme, enableSystem)
setThemeState(stored)
applyThemeClass(resolveTheme(stored, media.matches ? "dark" : "light"), false)
setReady(true)
const onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY || !event.newValue) return
const next = event.newValue
if (next !== "light" && next !== "dark" && next !== "system") return
setThemeState(next)
}
window.addEventListener("storage", onStorage)
return () => {
media.removeEventListener("change", onSystemChange)
window.removeEventListener("storage", onStorage)
}
}, [defaultTheme, enableSystem])
useEffect(() => {
if (!ready) return
applyThemeClass(resolveTheme(theme, systemTheme), disableTransitionOnChange)
}, [theme, systemTheme, ready, disableTransitionOnChange])
const setTheme = useCallback((value: Theme | ((prev: Theme) => Theme)) => {
setThemeState((prev) => {
const next = typeof value === "function" ? value(prev) : value
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
// ignore
}
return next
})
}, [])
const value = useMemo<ThemeContextValue>(
() => ({
theme: ready ? theme : undefined,
setTheme,
resolvedTheme: ready ? resolveTheme(theme, systemTheme) : undefined,
systemTheme: ready ? systemTheme : undefined,
themes: enableSystem ? THEMES : (["light", "dark"] as const),
}),
[ready, theme, setTheme, systemTheme, enableSystem],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const value = useContext(ThemeContext)
if (!value) {
return {
theme: undefined,
setTheme: () => {},
resolvedTheme: undefined,
systemTheme: undefined,
themes: [] as const,
}
}
return value
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"
import { useTheme } from "next-themes"
import { useTheme } from "@/components/theme-provider"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
+1 -26
View File
@@ -7,6 +7,7 @@
"": {
"name": "mmapp",
"version": "0.1.0",
"hasInstallScript": true,
"workspaces": [
"packages/*",
"backend"
@@ -18,7 +19,6 @@
"clsx": "^2.1.1",
"lucide-react": "^1.11.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.5.0",
@@ -9017,16 +9017,6 @@
}
}
},
"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/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
@@ -12684,21 +12674,6 @@
"dependencies": {
"zod": "^4.4.1"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
-1
View File
@@ -20,7 +20,6 @@
"clsx": "^2.1.1",
"lucide-react": "^1.11.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.5.0",