diff --git a/apps/frontend/index.html b/apps/frontend/index.html index abec82b..e041685 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -4,6 +4,14 @@ Personal Admin +
diff --git a/apps/frontend/src/components/layout/Header.tsx b/apps/frontend/src/components/layout/Header.tsx index 5d0bdb1..d04455e 100644 --- a/apps/frontend/src/components/layout/Header.tsx +++ b/apps/frontend/src/components/layout/Header.tsx @@ -1,6 +1,7 @@ -import { Menu } from "lucide-react"; +import { Menu, Sun, Moon, Monitor } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useSseStatus } from "@/lib/sse-context"; +import { useTheme } from "@/lib/theme"; import { cn } from "@/lib/utils"; interface HeaderProps { @@ -9,6 +10,11 @@ interface HeaderProps { export function Header({ onMenuClick }: HeaderProps) { const sseStatus = useSseStatus(); + const { theme, cycleTheme } = useTheme(); + + const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; + const themeLabel = + theme === "light" ? "Claro" : theme === "dark" ? "Oscuro" : "Sistema"; return (
@@ -22,6 +28,14 @@ export function Header({ onMenuClick }: HeaderProps) {
+ void; + cycleTheme: () => void; +} + +const ThemeContext = createContext(null); + +const STORAGE_KEY = "theme"; +const THEME_CYCLE: Theme[] = ["light", "dark", "system"]; + +function getStoredTheme(): Theme { + if (typeof localStorage === "undefined") return "system"; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark" || stored === "system") return stored; + return "system"; +} + +function getSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function applyThemeClass(resolved: "light" | "dark") { + const root = document.documentElement; + root.classList.toggle("dark", resolved === "dark"); +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setThemeState] = useState(getStoredTheme); + + const resolved = theme === "system" ? getSystemTheme() : theme; + + const setTheme = useCallback((t: Theme) => { + setThemeState(t); + localStorage.setItem(STORAGE_KEY, t); + }, []); + + const cycleTheme = useCallback(() => { + const idx = THEME_CYCLE.indexOf(theme); + setTheme(THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]); + }, [theme, setTheme]); + + useEffect(() => { + applyThemeClass(resolved); + }, [resolved]); + + useEffect(() => { + if (theme !== "system") return; + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => { + applyThemeClass(getSystemTheme()); + // force re-render so `resolved` updates + setThemeState("system"); + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [theme]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "d" && !e.shiftKey && !e.altKey) { + e.preventDefault(); + cycleTheme(); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [cycleTheme]); + + return ( + + {children} + + ); +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); + return ctx; +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index bd4fb33..aba9cff 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ThemeProvider } from "@/lib/theme"; import App from "./App"; import "./index.css"; @@ -17,9 +18,11 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")!).render( - - - - + + + + + + , );