From 9012ee71441ea13065178e65295fb7c4eea553f6 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Sun, 8 Mar 2026 22:59:31 +0200 Subject: [PATCH] feat: add dark theme support and increase contrast --- frontend/src/App.tsx | 7 +-- .../charts/EventDurationLineChart.tsx | 3 + .../components/common/AutoRefreshControl.tsx | 1 + frontend/src/components/common/CodeBlock.tsx | 2 +- frontend/src/components/layout/AppLayout.tsx | 11 +++- .../src/components/package/ChangesTab.tsx | 6 +- .../components/package/PackagePatchesList.tsx | 2 +- frontend/src/contexts/ThemeContext.ts | 27 +++++++++ frontend/src/contexts/ThemeProvider.tsx | 56 +++++++++++++++++++ frontend/src/hooks/useThemeMode.ts | 25 +++++++++ frontend/src/theme/StatusColors.ts | 12 ++-- frontend/src/theme/Theme.ts | 25 +++++---- 12 files changed, 150 insertions(+), 27 deletions(-) create mode 100644 frontend/src/contexts/ThemeContext.ts create mode 100644 frontend/src/contexts/ThemeProvider.tsx create mode 100644 frontend/src/hooks/useThemeMode.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9da94548..da1db045 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,16 +17,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import CssBaseline from "@mui/material/CssBaseline"; -import { ThemeProvider } from "@mui/material/styles"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import AppLayout from "components/layout/AppLayout"; import { AuthProvider } from "contexts/AuthProvider"; import { ClientProvider } from "contexts/ClientProvider"; import { NotificationProvider } from "contexts/NotificationProvider"; import { RepositoryProvider } from "contexts/RepositoryProvider"; +import { ThemeProvider } from "contexts/ThemeProvider"; import type React from "react"; -import Theme from "theme/Theme"; const queryClient = new QueryClient({ defaultOptions: { @@ -39,8 +37,7 @@ const queryClient = new QueryClient({ export default function App(): React.JSX.Element { return - - + diff --git a/frontend/src/components/charts/EventDurationLineChart.tsx b/frontend/src/components/charts/EventDurationLineChart.tsx index 15768204..0c1744bb 100644 --- a/frontend/src/components/charts/EventDurationLineChart.tsx +++ b/frontend/src/components/charts/EventDurationLineChart.tsx @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +import { blue } from "@mui/material/colors"; import type { Event } from "models/Event"; import type React from "react"; import { Line } from "react-chartjs-2"; @@ -33,6 +34,8 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar { label: "update duration, s", data: updateEvents.map(event => event.data?.took ?? 0), + borderColor: blue[500], + backgroundColor: blue[200], cubicInterpolationMode: "monotone" as const, tension: 0.4, }, diff --git a/frontend/src/components/common/AutoRefreshControl.tsx b/frontend/src/components/common/AutoRefreshControl.tsx index aa872554..bf125a2d 100644 --- a/frontend/src/components/common/AutoRefreshControl.tsx +++ b/frontend/src/components/common/AutoRefreshControl.tsx @@ -47,6 +47,7 @@ export default function AutoRefreshControl({ setAnchorEl(event.currentTarget)} color={enabled ? "primary" : "default"} > diff --git a/frontend/src/components/common/CodeBlock.tsx b/frontend/src/components/common/CodeBlock.tsx index 56917000..cbd9cbad 100644 --- a/frontend/src/components/common/CodeBlock.tsx +++ b/frontend/src/components/common/CodeBlock.tsx @@ -42,7 +42,7 @@ export default function CodeBlock({ component="pre" onScroll={onScroll} sx={{ - backgroundColor: "grey.100", + backgroundColor: "action.hover", p: 2, borderRadius: 1, overflow: "auto", diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 38205773..f27bb2df 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -17,7 +17,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import { Box, Container } from "@mui/material"; +import Brightness4Icon from "@mui/icons-material/Brightness4"; +import Brightness7Icon from "@mui/icons-material/Brightness7"; +import { Box, Container, IconButton, Tooltip } from "@mui/material"; import { useQuery } from "@tanstack/react-query"; import LoginDialog from "components/dialogs/LoginDialog"; import Footer from "components/layout/Footer"; @@ -27,6 +29,7 @@ import { QueryKeys } from "hooks/QueryKeys"; import { useAuth } from "hooks/useAuth"; import { useClient } from "hooks/useClient"; import { useRepository } from "hooks/useRepository"; +import { useThemeMode } from "hooks/useThemeMode"; import type { InfoResponse } from "models/InfoResponse"; import React, { useEffect, useState } from "react"; @@ -34,6 +37,7 @@ export default function AppLayout(): React.JSX.Element { const client = useClient(); const { setAuthState } = useAuth(); const { setRepositories } = useRepository(); + const { mode, toggleTheme } = useThemeMode(); const [loginOpen, setLoginOpen] = useState(false); const { data: info } = useQuery({ @@ -58,6 +62,11 @@ export default function AppLayout(): React.JSX.Element { + + + {mode === "dark" ? : } + + ({ queryKey: QueryKeys.changes(packageBase, repository), @@ -50,7 +52,7 @@ export default function ChangesTab({ packageBase, repository }: ChangesTabProps) return {editable && - onDelete(patch.key)}> + onDelete(patch.key)}> } diff --git a/frontend/src/contexts/ThemeContext.ts b/frontend/src/contexts/ThemeContext.ts new file mode 100644 index 00000000..edbf658d --- /dev/null +++ b/frontend/src/contexts/ThemeContext.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { createContext } from "react"; + +export interface ThemeContextValue { + mode: "light" | "dark"; + toggleTheme: () => void; +} + +export const ThemeContext = createContext(null); diff --git a/frontend/src/contexts/ThemeProvider.tsx b/frontend/src/contexts/ThemeProvider.tsx new file mode 100644 index 00000000..4b33256a --- /dev/null +++ b/frontend/src/contexts/ThemeProvider.tsx @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import CssBaseline from "@mui/material/CssBaseline"; +import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles"; +import { defaults as chartDefaults } from "chart.js"; +import { ThemeContext } from "contexts/ThemeContext"; +import { useLocalStorage } from "hooks/useLocalStorage"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { createAppTheme } from "theme/Theme"; + +function systemPreference(): "light" | "dark" { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function ThemeProvider({ children }: { children: React.ReactNode }): React.JSX.Element { + const [mode, setMode] = useLocalStorage<"light" | "dark">("theme-mode", systemPreference()); + + const toggleTheme = useCallback(() => { + setMode(prev => prev === "light" ? "dark" : "light"); + }, [setMode]); + + const theme = useMemo(() => createAppTheme(mode), [mode]); + + useEffect(() => { + const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)"; + const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)"; + chartDefaults.color = textColor; + chartDefaults.borderColor = gridColor; + }, [mode]); + + const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]); + + return + + + {children} + + ; +} diff --git a/frontend/src/hooks/useThemeMode.ts b/frontend/src/hooks/useThemeMode.ts new file mode 100644 index 00000000..1d9a9576 --- /dev/null +++ b/frontend/src/hooks/useThemeMode.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { ThemeContext, type ThemeContextValue } from "contexts/ThemeContext"; +import { useContextNotNull } from "hooks/useContextNotNull"; + +export function useThemeMode(): ThemeContextValue { + return useContextNotNull(ThemeContext); +} diff --git a/frontend/src/theme/StatusColors.ts b/frontend/src/theme/StatusColors.ts index fa900fb4..129b764a 100644 --- a/frontend/src/theme/StatusColors.ts +++ b/frontend/src/theme/StatusColors.ts @@ -21,15 +21,15 @@ import { amber, green, grey, orange, red } from "@mui/material/colors"; import type { BuildStatus } from "models/BuildStatus"; const base: Record = { - unknown: grey[800], - pending: amber[900], - building: orange[900], - failed: red[900], - success: green[800], + unknown: grey[600], + pending: amber[700], + building: orange[800], + failed: red[700], + success: green[700], }; const headerBase: Record = { - unknown: grey[800], + unknown: grey[600], pending: amber[700], building: orange[600], failed: red[500], diff --git a/frontend/src/theme/Theme.ts b/frontend/src/theme/Theme.ts index d08acc1b..6b7168fc 100644 --- a/frontend/src/theme/Theme.ts +++ b/frontend/src/theme/Theme.ts @@ -17,17 +17,20 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import { createTheme } from "@mui/material/styles"; +import { createTheme, type Theme } from "@mui/material/styles"; -const Theme = createTheme({ - components: { - MuiDialog: { - defaultProps: { - maxWidth: "lg", - fullWidth: true, +export function createAppTheme(mode: "light" | "dark"): Theme { + return createTheme({ + palette: { + mode, + }, + components: { + MuiDialog: { + defaultProps: { + maxWidth: "lg", + fullWidth: true, + }, }, }, - }, -}); - -export default Theme; + }); +}