feat(panel): light/dark theme

This commit is contained in:
Lemon-miaow committed 2026-06-27 18:48:12 +08:00
1 parent dd2fc6fa82
commit 7b458daf77
7 files changed
+162 -12

No files matched your search

+12 -1
View File
@@ -1,9 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en" class="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Felis · Control Panel</title> <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);
if (dark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
} catch (_) {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+5 -2
View File
@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "@/lib/theme";
import { TierProvider } from "@/lib/tier"; import { TierProvider } from "@/lib/tier";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAdmin } from "@/components/RequireAdmin";
@@ -23,8 +24,9 @@ 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 (
<TierProvider> <ThemeProvider storageKey="felis-theme">
<BrowserRouter> <TierProvider>
<BrowserRouter>
<Routes> <Routes>
{/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
RequireAuth — RequireAuth redirects here — and outside AppShell, so RequireAuth — RequireAuth redirects here — and outside AppShell, so
@@ -62,5 +64,6 @@ export default function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</TierProvider> </TierProvider>
</ThemeProvider>
); );
} }
+37 -7
View File
@@ -1,19 +1,20 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { Cat, Globe } from "lucide-react"; import { Cat, Globe, Sun, Moon } 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";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { visibleSections } from "@/lib/nav"; import { visibleSections } from "@/lib/nav";
import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { useTheme } from "@/lib/theme";
function LangToggle() { function LangToggle() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
return ( return (
<Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}> <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
<SelectPrimitive.Trigger className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-zinc-900/50 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-colors shrink-0"> <SelectPrimitive.Trigger className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-muted/40 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-colors shrink-0">
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
<SelectContent align="start" className="min-w-[6rem]"> <SelectContent align="start" className="min-w-[6rem]">
@@ -24,6 +25,26 @@ function LangToggle() {
); );
} }
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation("common");
return (
<button
onClick={toggleTheme}
className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-muted/40 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-all duration-200 active:scale-95 shrink-0"
aria-label={t("toggle_theme")}
title={t("toggle_theme")}
>
{theme === "light" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
);
}
export function AppShell() { export function AppShell() {
const { isAdmin } = useTier(); const { isAdmin } = useTier();
const { t, i18n } = useTranslation("navigation"); const { t, i18n } = useTranslation("navigation");
@@ -77,8 +98,11 @@ export function AppShell() {
</nav> </nav>
<div className="mt-auto px-3 pt-4 border-t border-border/60"> <div className="mt-auto px-3 pt-4 border-t border-border/60">
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2">
<LangToggle /> <div className="flex items-center gap-1.5 shrink-0">
<LangToggle />
<ThemeToggle />
</div>
<span className="text-[10px] text-muted-foreground/60 leading-tight pt-1"> <span className="text-[10px] text-muted-foreground/60 leading-tight pt-1">
{t("common:brand_tagline")} {t("common:brand_tagline")}
</span> </span>
@@ -87,9 +111,15 @@ export function AppShell() {
</aside> </aside>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center gap-2 border-b border-border px-4 md:hidden"> <header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden">
<Cat className="h-5 w-5 text-primary" /> <div className="flex items-center gap-2">
<span className="font-semibold">{t("common:brand_name")}</span> <Cat className="h-5 w-5 text-primary" />
<span className="font-semibold">{t("common:brand_name")}</span>
</div>
<div className="flex items-center gap-2">
<LangToggle />
<ThemeToggle />
</div>
</header> </header>
<main className="flex-1 p-6"> <main className="flex-1 p-6">
<Outlet /> <Outlet />
+2 -1
View File
@@ -18,5 +18,6 @@
"page_title": "Felis · Control Panel", "page_title": "Felis · Control Panel",
"lang_en": "EN", "lang_en": "EN",
"lang_zh": "中文", "lang_zh": "中文",
"lang_toggle_hint": "Switch to {{lang}}" "lang_toggle_hint": "Switch to {{lang}}",
"toggle_theme": "Toggle theme"
} }
+2 -1
View File
@@ -18,5 +18,6 @@
"page_title": "Felis · 控制面板", "page_title": "Felis · 控制面板",
"lang_en": "EN", "lang_en": "EN",
"lang_zh": "中文", "lang_zh": "中文",
"lang_toggle_hint": "切换至 {{lang}}" "lang_toggle_hint": "切换至 {{lang}}",
"toggle_theme": "切换主题"
} }
+6
View File
@@ -55,4 +55,10 @@
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;
}
} }
+98
View File
@@ -0,0 +1,98 @@
import { createContext, useContext, useEffect, useState } from "react";
export type Theme = "light" | "dark";
type ThemeProviderProps = {
children: React.ReactNode;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
};
const initialState: 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";
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
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 = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};