feat(panel): light/dark theme
This commit is contained in:
7 files changed
+162
-12
No files matched your search
+12
-1
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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": "切换主题"
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in new issue
Block a user