fix(panel): reactive system theme detection
This commit is contained in:
5 files changed
+85
-91
No files matched your search
+1
-2
@@ -6,8 +6,7 @@
|
||||
<title>Felis · Control Panel</title>
|
||||
<script>
|
||||
try {
|
||||
const theme = localStorage.getItem("felis-theme") || "system";
|
||||
const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (dark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import { FleetTable } from "@/pages/ops/FleetTable";
|
||||
// the split is by concern, not by tier. TierProvider fetches /me once at boot.
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider storageKey="felis-theme">
|
||||
<ThemeProvider>
|
||||
<TierProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { Cat, Globe, Sun, Moon, LogOut, UserRound } from "lucide-react";
|
||||
import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -130,7 +130,8 @@ function ThemeToggle() {
|
||||
aria-label={t("toggle_theme")}
|
||||
title={t("toggle_theme")}
|
||||
>
|
||||
{theme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{theme === "light" && <Sun className="h-4 w-4" />}
|
||||
{theme === "dark" && <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,10 +55,4 @@
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Global theme toggle transition */
|
||||
.theme-transitioning,
|
||||
.theme-transitioning * {
|
||||
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, text-decoration-color 150ms ease, fill 150ms ease, stroke 150ms ease !important;
|
||||
}
|
||||
}
|
||||
+80
-80
@@ -1,98 +1,98 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
/** The resolved, active theme ("light" | "dark") applied to the DOM. */
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
const ThemeContext = createContext<ThemeProviderState>({
|
||||
theme: "dark",
|
||||
setTheme: () => null,
|
||||
toggleTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
storageKey = "felis-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem(storageKey) as Theme | null;
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return stored;
|
||||
}
|
||||
// Auto-detect system preference
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
setTheme: () => {},
|
||||
toggleTheme: () => {},
|
||||
});
|
||||
|
||||
const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
// ---- system theme store ----
|
||||
|
||||
function systemThemeQuery() {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return null;
|
||||
return window.matchMedia(SYSTEM_DARK_QUERY);
|
||||
}
|
||||
|
||||
function systemThemeSnapshot(): Theme {
|
||||
return systemThemeQuery()?.matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyClass(t: Theme) {
|
||||
const root = document.documentElement;
|
||||
if (t === "dark") {
|
||||
root.classList.add("dark");
|
||||
root.classList.remove("light");
|
||||
} else {
|
||||
root.classList.add("light");
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- provider ----
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(systemThemeSnapshot);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(next: Theme) => {
|
||||
// Direct DOM write to prevent visual lag on transition.
|
||||
setThemeState(next);
|
||||
applyClass(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
const mql = systemThemeQuery();
|
||||
if (!mql) return;
|
||||
|
||||
const syncSystemTheme = () => setThemeState(mql.matches ? "dark" : "light");
|
||||
syncSystemTheme();
|
||||
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener("change", syncSystemTheme);
|
||||
return () => mql.removeEventListener("change", syncSystemTheme);
|
||||
}
|
||||
|
||||
mql.addListener?.(syncSystemTheme);
|
||||
return () => mql.removeListener?.(syncSystemTheme);
|
||||
}, []);
|
||||
|
||||
// Sync the class whenever the resolved theme changes.
|
||||
useEffect(() => {
|
||||
applyClass(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen to system preference changes ONLY IF the user hasn't explicitly set a preference yet
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.add("theme-transitioning");
|
||||
setThemeState(e.matches ? "dark" : "light");
|
||||
setTimeout(() => {
|
||||
root.classList.remove("theme-transitioning");
|
||||
}, 150);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleSystemThemeChange);
|
||||
};
|
||||
}, [storageKey]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.add("theme-transitioning");
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setThemeState(newTheme);
|
||||
setTimeout(() => {
|
||||
root.classList.remove("theme-transitioning");
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider
|
||||
{...props}
|
||||
value={{ theme, setTheme, toggleTheme }}
|
||||
>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
const value = useMemo<ThemeProviderState>(
|
||||
() => ({ theme, setTheme, toggleTheme }),
|
||||
[theme, setTheme, toggleTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
Reference in new issue
Block a user