fix(panel): reactive system theme detection

This commit is contained in:
Lemon-miaow committed 2026-06-28 03:41:50 +08:00
1 parent f5d00f389e
commit 832b200d55
5 files changed
+86 -92

No files matched your search

+1 -2
View File
@@ -6,8 +6,7 @@
<title>Felis · Control Panel</title> <title>Felis · Control Panel</title>
<script> <script>
try { try {
const theme = localStorage.getItem("felis-theme") || "system"; const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) { if (dark) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} else { } else {
+1 -1
View File
@@ -22,7 +22,7 @@ import { FleetTable } from "@/pages/ops/FleetTable";
// the split is by concern, not by tier. TierProvider fetches /me once at boot. // the split is by concern, not by tier. TierProvider fetches /me once at boot.
export default function App() { export default function App() {
return ( return (
<ThemeProvider storageKey="felis-theme"> <ThemeProvider>
<TierProvider> <TierProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
+3 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom"; 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 { useTranslation } from "react-i18next";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -130,7 +130,8 @@ function ThemeToggle() {
aria-label={t("toggle_theme")} aria-label={t("toggle_theme")}
title={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> </button>
); );
} }
-6
View File
@@ -55,10 +55,4 @@
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased; -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;
}
} }
+81 -81
View File
@@ -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"; export type Theme = "light" | "dark";
type ThemeProviderProps = {
children: React.ReactNode;
storageKey?: string;
};
type ThemeProviderState = { type ThemeProviderState = {
/** The resolved, active theme ("light" | "dark") applied to the DOM. */
theme: Theme; theme: Theme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; toggleTheme: () => void;
}; };
const initialState: ThemeProviderState = { const ThemeContext = createContext<ThemeProviderState>({
theme: "dark", theme: "dark",
setTheme: () => null, setTheme: () => {},
toggleTheme: () => null, toggleTheme: () => {},
}; });
const ThemeProviderContext = createContext<ThemeProviderState>(initialState); const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)";
export function ThemeProvider({ // ---- system theme store ----
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";
});
useEffect(() => { function systemThemeQuery() {
const root = window.document.documentElement; if (typeof window === "undefined" || !window.matchMedia) return null;
root.classList.remove("light", "dark"); return window.matchMedia(SYSTEM_DARK_QUERY);
root.classList.add(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>
);
} }
export const useTheme = () => { function systemThemeSnapshot(): Theme {
const context = useContext(ThemeProviderContext); return systemThemeQuery()?.matches ? "dark" : "light";
}
if (context === undefined) function applyClass(t: Theme) {
throw new Error("useTheme must be used within a ThemeProvider"); const root = document.documentElement;
if (t === "dark") {
root.classList.add("dark");
root.classList.remove("light");
} else {
root.classList.add("light");
root.classList.remove("dark");
}
}
return context; // ---- 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 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]);
const value = useMemo<ThemeProviderState>(
() => ({ theme, setTheme, toggleTheme }),
[theme, setTheme, toggleTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
return useContext(ThemeContext);
}