From fef8f60ee2ed73c890683dbe4138acc7246a8aee Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 21 Apr 2026 19:30:29 +0200 Subject: [PATCH] Add dark mode support with persisted OS-aware theme selection --- Docs/Tasks.md | 4 +- Docs/Web-Design.md | 1 + frontend/src/App.tsx | 18 +++- frontend/src/components/BanTrendChart.tsx | 36 ++++--- .../src/components/JailDistributionChart.tsx | 17 ++-- .../src/components/TopCountriesBarChart.tsx | 17 ++-- .../src/components/TopCountriesPieChart.tsx | 9 +- frontend/src/layouts/MainLayout.tsx | 71 ++++++++++---- .../src/layouts/__tests__/MainLayout.test.tsx | 5 + frontend/src/providers/ThemeProvider.tsx | 98 +++++++++++++++++++ .../__tests__/ThemeProvider.test.tsx | 68 +++++++++++++ 11 files changed, 293 insertions(+), 51 deletions(-) create mode 100644 frontend/src/providers/ThemeProvider.tsx create mode 100644 frontend/src/providers/__tests__/ThemeProvider.test.tsx diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 7bd9d58..ef06afe 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -433,7 +433,9 @@ const source = timeRange === "24h" ? "fail2ban" : "archive"; --- -### TASK-022 — Add dark mode support and OS preference detection +### TASK-022 — Add dark mode support and OS preference detection (done) + +**Where fixed:** `frontend/src/App.tsx`, `frontend/src/providers/ThemeProvider.tsx`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/theme/customTheme.ts`, `frontend/src/components/BanTrendChart.tsx`, `frontend/src/components/TopCountriesPieChart.tsx`, `frontend/src/components/TopCountriesBarChart.tsx`, `frontend/src/components/JailDistributionChart.tsx`, `Docs/Web-Design.md` **Where found:** `frontend/src/App.tsx` line 25 — `` is hardcoded. `frontend/src/theme/customTheme.ts` already exports `darkTheme` but it is never used. diff --git a/Docs/Web-Design.md b/Docs/Web-Design.md index 2488bbd..7aae979 100644 --- a/Docs/Web-Design.md +++ b/Docs/Web-Design.md @@ -41,6 +41,7 @@ BanGUI uses a **single custom theme** generated with the [Fluent UI Theme Design - The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements. - Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ. +- Persist the user's explicit theme choice in `localStorage` and otherwise follow the operating system's `prefers-color-scheme` setting. - Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly. ### Colour Rules diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67093d2..8e8322e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,8 +22,9 @@ import { lazy, Suspense } from "react"; import { FluentProvider, Spinner } from "@fluentui/react-components"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import { lightTheme } from "./theme/customTheme"; +import { darkTheme, lightTheme } from "./theme/customTheme"; import { AuthProvider } from "./providers/AuthProvider"; +import { ThemeProvider, useThemeMode } from "./providers/ThemeProvider"; import { TimezoneProvider } from "./providers/TimezoneProvider"; import { RequireAuth } from "./components/RequireAuth"; import { SetupGuard } from "./components/SetupGuard"; @@ -43,9 +44,12 @@ const BlocklistsPage = lazy(() => import("./pages/BlocklistsPage").then((m) => ( /** * Root application component — mounts providers and top-level routes. */ -function App(): React.JSX.Element { +function AppContents(): React.JSX.Element { + const { colorMode } = useThemeMode(); + const theme = colorMode === "dark" ? darkTheme : lightTheme; + return ( - + }> @@ -96,4 +100,12 @@ function App(): React.JSX.Element { ); } +function App(): React.JSX.Element { + return ( + + + + ); +} + export default App; diff --git a/frontend/src/components/BanTrendChart.tsx b/frontend/src/components/BanTrendChart.tsx index 5434cbf..10baca2 100644 --- a/frontend/src/components/BanTrendChart.tsx +++ b/frontend/src/components/BanTrendChart.tsx @@ -25,6 +25,7 @@ import { CHART_PALETTE, resolveFluentToken, } from "../utils/chartTheme"; +import { useThemeMode } from "../providers/ThemeProvider"; import { ChartStateWrapper } from "./ChartStateWrapper"; import { useBanTrend } from "../hooks/useBanTrend"; import type { BanOriginFilter, TimeRange } from "../types/ban"; @@ -200,29 +201,36 @@ export const BanTrendChart = memo(function BanTrendChart({ source = "fail2ban", }: BanTrendChartProps): React.JSX.Element { const styles = useStyles(); + const { colorMode } = useThemeMode(); const { buckets, loading, error, reload } = useBanTrend(timeRange, origin, source); const isEmpty = buckets.every((b) => b.count === 0); const entries = buildEntries(buckets, timeRange); const { primaryColour, axisColour, gridColour } = useMemo( - () => ({ - primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), - axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), - gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), - }), - [], + () => { + void colorMode; + return { + primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), + axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), + gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), + }; + }, + [colorMode], ); const tickInterval = TICK_INTERVAL[timeRange]; const tooltipContent = useMemo( - () => ( - - ), - [], + () => { + void colorMode; + return ( + + ); + }, + [colorMode], ); return ( diff --git a/frontend/src/components/JailDistributionChart.tsx b/frontend/src/components/JailDistributionChart.tsx index 7fd9212..77c542e 100644 --- a/frontend/src/components/JailDistributionChart.tsx +++ b/frontend/src/components/JailDistributionChart.tsx @@ -24,6 +24,7 @@ import { CHART_PALETTE, resolveFluentToken, } from "../utils/chartTheme"; +import { useThemeMode } from "../providers/ThemeProvider"; import { ChartStateWrapper } from "./ChartStateWrapper"; import { ChartTooltip } from "./ChartTooltip"; import { useJailDistribution } from "../hooks/useJailDistribution"; @@ -134,17 +135,21 @@ export const JailDistributionChart = memo(function JailDistributionChart({ origin, }: JailDistributionChartProps): React.JSX.Element { const styles = useStyles(); + const { colorMode } = useThemeMode(); const { jails, loading, error, reload } = useJailDistribution(timeRange, origin); const entries = buildEntries(jails); const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); const { primaryColour, axisColour, gridColour } = useMemo( - () => ({ - primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), - axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), - gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), - }), - [], + () => { + void colorMode; + return { + primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), + axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), + gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), + }; + }, + [colorMode], ); return ( diff --git a/frontend/src/components/TopCountriesBarChart.tsx b/frontend/src/components/TopCountriesBarChart.tsx index 679f04d..4e6d6b2 100644 --- a/frontend/src/components/TopCountriesBarChart.tsx +++ b/frontend/src/components/TopCountriesBarChart.tsx @@ -21,6 +21,7 @@ import { CHART_GRID_LINE_TOKEN, resolveFluentToken, } from "../utils/chartTheme"; +import { useThemeMode } from "../providers/ThemeProvider"; import { ChartTooltip } from "./ChartTooltip"; // --------------------------------------------------------------------------- @@ -142,16 +143,20 @@ export const TopCountriesBarChart = memo(function TopCountriesBarChart({ countryNames, }: TopCountriesBarChartProps): React.JSX.Element { const styles = useStyles(); + const { colorMode } = useThemeMode(); const entries = buildEntries(countries, countryNames); const { primaryColour, axisColour, gridColour } = useMemo( - () => ({ - primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), - axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), - gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), - }), - [], + () => { + void colorMode; + return { + primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), + axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), + gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), + }; + }, + [colorMode], ); if (entries.length === 0) { diff --git a/frontend/src/components/TopCountriesPieChart.tsx b/frontend/src/components/TopCountriesPieChart.tsx index 945befe..87b3c33 100644 --- a/frontend/src/components/TopCountriesPieChart.tsx +++ b/frontend/src/components/TopCountriesPieChart.tsx @@ -17,6 +17,7 @@ import type { LegendPayload } from "recharts/types/component/DefaultLegendConten import type { TooltipContentProps } from "recharts/types/component/Tooltip"; import { tokens, makeStyles, Text } from "@fluentui/react-components"; import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme"; +import { useThemeMode } from "../providers/ThemeProvider"; import { ChartTooltip } from "./ChartTooltip"; // --------------------------------------------------------------------------- @@ -134,10 +135,14 @@ export const TopCountriesPieChart = memo(function TopCountriesPieChart({ countryNames, }: TopCountriesPieChartProps): React.JSX.Element { const styles = useStyles(); + const { colorMode } = useThemeMode(); const resolvedPalette = useMemo( - () => CHART_PALETTE.map(resolveFluentToken), - [], + () => { + void colorMode; + return CHART_PALETTE.map(resolveFluentToken); + }, + [colorMode], ); const slices = buildSlices(countries, countryNames, resolvedPalette); const total = slices.reduce((sum, s) => sum + s.value, 0); diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 5501cae..9978ece 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -28,11 +28,14 @@ import { ListRegular, SignOutRegular, NavigationRegular, + WeatherMoonRegular, + WeatherSunnyRegular, } from "@fluentui/react-icons"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { useAuth } from "../hooks/useAuth"; import { useServerStatus } from "../hooks/useServerStatus"; import { useBlocklistStatus } from "../hooks/useBlocklist"; +import { useThemeMode } from "../providers/ThemeProvider"; // --------------------------------------------------------------------------- // Styles @@ -146,11 +149,20 @@ const useStyles = makeStyles({ padding: tokens.spacingVerticalS, flexShrink: 0, }, + footerActions: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + }, + themeButton: { + width: "100%", + justifyContent: "flex-start", + }, logoutButton: { width: "100%", justifyContent: "flex-start", }, - logoutButtonCollapsed: { + sidebarButtonCollapsed: { justifyContent: "center", }, versionText: { @@ -220,6 +232,7 @@ const NAV_ITEMS: NavItem[] = [ export function MainLayout(): React.JSX.Element { const styles = useStyles(); const { logout } = useAuth(); + const { colorMode, toggleColorMode } = useThemeMode(); const navigate = useNavigate(); const readSavedCollapsed = (): boolean => { @@ -346,31 +359,51 @@ export function MainLayout(): React.JSX.Element { })} - {/* Footer — Logout */} + {/* Footer — Theme toggle and logout */}
{!collapsed && ( BanGUI )} - - - + + + + + +
diff --git a/frontend/src/layouts/__tests__/MainLayout.test.tsx b/frontend/src/layouts/__tests__/MainLayout.test.tsx index ca53288..2c388d1 100644 --- a/frontend/src/layouts/__tests__/MainLayout.test.tsx +++ b/frontend/src/layouts/__tests__/MainLayout.test.tsx @@ -89,4 +89,9 @@ describe("MainLayout", () => { await userEvent.click(toggleButton); expect(localStorage.getItem("bangui_sidebar_collapsed")).toBe("true"); }); + + it("renders a theme toggle button in the sidebar footer", () => { + renderLayout(); + expect(screen.getByRole("button", { name: /switch to dark mode/i })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/providers/ThemeProvider.tsx b/frontend/src/providers/ThemeProvider.tsx new file mode 100644 index 0000000..fe466ff --- /dev/null +++ b/frontend/src/providers/ThemeProvider.tsx @@ -0,0 +1,98 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; + +type ThemeMode = "light" | "dark"; + +interface ThemeProviderContext { + colorMode: ThemeMode; + toggleColorMode: () => void; + setColorMode: (mode: ThemeMode) => void; + hasExplicitPreference: boolean; +} + +const THEME_STORAGE_KEY = "bangui_theme"; +const ThemeModeContext = createContext(null); + +function readSavedTheme(): ThemeMode | null { + try { + const value = localStorage.getItem(THEME_STORAGE_KEY); + if (value === "dark" || value === "light") { + return value; + } + } catch { + // Ignore localStorage failures. + } + return null; +} + +function getSystemTheme(): ThemeMode { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +interface ThemeProviderProps { + children: ReactNode; +} + +export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element { + const savedTheme = readSavedTheme(); + const [colorMode, setColorModeState] = useState(() => savedTheme ?? getSystemTheme()); + const [hasExplicitPreference, setHasExplicitPreference] = useState(() => savedTheme !== null); + + const setColorMode = useCallback((mode: ThemeMode): void => { + try { + localStorage.setItem(THEME_STORAGE_KEY, mode); + } catch { + // Ignore storage failures. + } + setHasExplicitPreference(true); + setColorModeState(mode); + }, []); + + const toggleColorMode = useCallback((): void => { + setColorMode(colorMode === "dark" ? "light" : "dark"); + }, [colorMode, setColorMode]); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (event: MediaQueryListEvent): void => { + if (!hasExplicitPreference) { + setColorModeState(event.matches ? "dark" : "light"); + } + }; + + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", handleChange); + function cleanup(): void { + mediaQuery.removeEventListener("change", handleChange); + } + return cleanup; + } + + return undefined; + }, [hasExplicitPreference]); + + const value = useMemo( + () => ({ colorMode, toggleColorMode, setColorMode, hasExplicitPreference }), + [colorMode, toggleColorMode, setColorMode, hasExplicitPreference], + ); + + return {children}; +} + +export function useThemeMode(): ThemeProviderContext { + const context = useContext(ThemeModeContext); + if (context != null) { + return context; + } + + return { + colorMode: "light", + toggleColorMode: (): void => { + /* no-op when provider is missing */ + }, + setColorMode: (): void => { + /* no-op when provider is missing */ + }, + hasExplicitPreference: false, + }; +} diff --git a/frontend/src/providers/__tests__/ThemeProvider.test.tsx b/frontend/src/providers/__tests__/ThemeProvider.test.tsx new file mode 100644 index 0000000..979d588 --- /dev/null +++ b/frontend/src/providers/__tests__/ThemeProvider.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ThemeProvider, useThemeMode } from "../ThemeProvider"; + +function ThemeConsumer(): React.JSX.Element { + const { colorMode, toggleColorMode } = useThemeMode(); + return ( +
+ {colorMode} + +
+ ); +} + +function renderWithProvider(): void { + render( + + + , + ); +} + +describe("ThemeProvider", () => { + beforeEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); + }); + + it("uses the saved theme from localStorage when present", () => { + localStorage.setItem("bangui_theme", "dark"); + renderWithProvider(); + expect(screen.getByTestId("color-mode")).toHaveTextContent("dark"); + }); + + it("falls back to the OS preference when no explicit theme is saved", () => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); + renderWithProvider(); + expect(screen.getByTestId("color-mode")).toHaveTextContent("dark"); + }); + + it("persists the user selection to localStorage when toggled", async () => { + renderWithProvider(); + expect(screen.getByTestId("color-mode")).toHaveTextContent("light"); + + await screen.getByRole("button", { name: /toggle/i }).click(); + expect(screen.getByTestId("color-mode")).toHaveTextContent("dark"); + expect(localStorage.getItem("bangui_theme")).toBe("dark"); + }); +});