From 7fc7ae1686bc1f34577679e7e2b67149730508d6 Mon Sep 17 00:00:00 2001 From: wilhel1812 Date: Sun, 7 Jun 2026 11:36:22 +0200 Subject: [PATCH] feat: add system appearance theme selection --- public/locales/en.json | 15 ++ src/components/CustomThemeManagement.css | 20 +- src/components/CustomThemeManagement.test.tsx | 110 ++++++++ src/components/CustomThemeManagement.tsx | 57 +++-- src/components/SettingsTab.tsx | 179 ++++++++----- src/contexts/SettingsContext.test.tsx | 237 ++++++++++++++++++ src/contexts/SettingsContext.tsx | 221 ++++++++++++---- src/server/constants/settings.ts | 3 + 8 files changed, 724 insertions(+), 118 deletions(-) create mode 100644 src/components/CustomThemeManagement.test.tsx diff --git a/public/locales/en.json b/public/locales/en.json index e10ae26fb..962e09049 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1399,6 +1399,15 @@ "settings.overlay_scheme_light": "Light Mode (saturated overlays)", "settings.theme_label": "Color Theme", "settings.theme_description": "Choose from 15 themes including accessibility-focused options", + "settings.appearance_mode_label": "Appearance Mode", + "settings.appearance_mode_description": "Choose a fixed appearance or follow your browser and operating system setting", + "settings.appearance_mode_system": "Match system appearance", + "settings.appearance_mode_dark": "Dark", + "settings.appearance_mode_light": "Light", + "settings.dark_theme_label": "Dark Theme", + "settings.dark_theme_description": "Theme used when appearance is set to Dark or the system is in dark mode", + "settings.light_theme_label": "Light Theme", + "settings.light_theme_description": "Theme used when appearance is set to Light or the system is in light mode", "settings.theme_catppuccin": "Catppuccin", "settings.theme_mocha": "Mocha (Dark)", "settings.theme_macchiato": "Macchiato (Medium-Dark)", @@ -2558,6 +2567,12 @@ "theme_management.built_in": "Built-in", "theme_management.active": "Active", "theme_management.apply": "Apply", + "theme_management.apply_dark": "Apply as Dark", + "theme_management.apply_light": "Apply as Light", + "theme_management.dark_active": "Dark Active", + "theme_management.light_active": "Light Active", + "theme_management.assigned_dark": "Dark", + "theme_management.assigned_light": "Light", "theme_management.clone": "Clone", "theme_editor.edit_title": "Edit Theme", diff --git a/src/components/CustomThemeManagement.css b/src/components/CustomThemeManagement.css index f1f4bdd08..0340c41d1 100644 --- a/src/components/CustomThemeManagement.css +++ b/src/components/CustomThemeManagement.css @@ -120,14 +120,32 @@ width: fit-content; } +.theme-assignment-badges { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.assignment-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + background: var(--ctp-blue); + color: var(--ctp-crust); + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-sm); + width: fit-content; +} + .theme-card-actions { display: flex; + flex-wrap: wrap; gap: 0.5rem; align-items: center; } .btn-apply { - flex: 1; + flex: 1 1 120px; padding: 0.5rem 1rem; background: var(--ctp-blue); color: var(--ctp-crust); diff --git a/src/components/CustomThemeManagement.test.tsx b/src/components/CustomThemeManagement.test.tsx new file mode 100644 index 000000000..32cbd35a7 --- /dev/null +++ b/src/components/CustomThemeManagement.test.tsx @@ -0,0 +1,110 @@ +/** + * Tests for custom theme assignment actions. + * + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CustomThemeManagement } from './CustomThemeManagement'; + +const setDarkTheme = vi.fn(); +const setLightTheme = vi.fn(); +const setAppearanceMode = vi.fn(); +const loadCustomThemes = vi.fn(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../contexts/SettingsContext', () => ({ + useSettings: () => ({ + customThemes: [ + { + id: 1, + name: 'Storm', + slug: 'custom-storm', + definition: JSON.stringify({ + base: '#111111', + text: '#eeeeee', + blue: '#89b4fa', + green: '#a6e3a1', + yellow: '#f9e2af', + red: '#f38ba8', + }), + is_builtin: 0, + created_at: 1, + updated_at: 1, + }, + ], + loadCustomThemes, + theme: 'mocha', + appearanceMode: 'system', + darkTheme: 'mocha', + lightTheme: 'latte', + setDarkTheme, + setLightTheme, + setAppearanceMode, + }), +})); + +vi.mock('../contexts/AuthContext', () => ({ + useAuth: () => ({ + authStatus: { + permissions: { + global: { + themes: { + write: true, + }, + }, + }, + }, + }), +})); + +vi.mock('../contexts/CsrfContext', () => ({ + useCsrf: () => ({ + getToken: () => 'csrf-token', + }), +})); + +vi.mock('../services/api', () => ({ + default: { + getBaseUrl: vi.fn().mockResolvedValue(''), + }, +})); + +vi.mock('./ThemeEditor', () => ({ + ThemeEditor: () =>
, +})); + +describe('CustomThemeManagement', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('applies a custom theme as the dark theme without changing light theme or mode', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'theme_management.apply_dark' })); + + expect(setDarkTheme).toHaveBeenCalledWith('custom-storm'); + expect(setLightTheme).not.toHaveBeenCalled(); + expect(setAppearanceMode).not.toHaveBeenCalled(); + }); + + it('applies a custom theme as the light theme without changing dark theme or mode', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'theme_management.apply_light' })); + + expect(setLightTheme).toHaveBeenCalledWith('custom-storm'); + expect(setDarkTheme).not.toHaveBeenCalled(); + expect(setAppearanceMode).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/CustomThemeManagement.tsx b/src/components/CustomThemeManagement.tsx index fcef0710c..5b2dcf7b4 100644 --- a/src/components/CustomThemeManagement.tsx +++ b/src/components/CustomThemeManagement.tsx @@ -9,7 +9,7 @@ import './CustomThemeManagement.css'; export const CustomThemeManagement: React.FC = () => { const { t } = useTranslation(); - const { customThemes, loadCustomThemes, theme, setTheme } = useSettings(); + const { customThemes, loadCustomThemes, theme, darkTheme, lightTheme, setDarkTheme, setLightTheme } = useSettings(); const { authStatus } = useAuth(); const { getToken: getCsrfToken } = useCsrf(); @@ -101,19 +101,17 @@ export const CustomThemeManagement: React.FC = () => { return; } - // If we deleted the active theme, switch to mocha - if (theme === themeSlug) { - setTheme('mocha'); + if (darkTheme === themeSlug) { + setDarkTheme('mocha'); + } + if (lightTheme === themeSlug) { + setLightTheme('latte'); } // Reload themes await loadCustomThemes(); }; - const handleApply = (themeSlug: string) => { - setTheme(themeSlug); - }; - if (isEditorOpen) { return ( { handleApply(customTheme.slug)} + onApplyDark={() => setDarkTheme(customTheme.slug)} + onApplyLight={() => setLightTheme(customTheme.slug)} onEdit={() => handleEdit(customTheme)} onClone={() => handleClone(customTheme)} onDelete={() => handleDelete(customTheme.slug)} @@ -171,9 +171,11 @@ export const CustomThemeManagement: React.FC = () => { interface ThemeCardProps { theme: CustomTheme; - isActive: boolean; + isDarkTheme: boolean; + isLightTheme: boolean; canWrite: boolean; - onApply: () => void; + onApplyDark: () => void; + onApplyLight: () => void; onEdit: () => void; onClone: () => void; onDelete: () => void; @@ -181,9 +183,11 @@ interface ThemeCardProps { const ThemeCard: React.FC = ({ theme, - isActive, + isDarkTheme, + isLightTheme, canWrite, - onApply, + onApplyDark, + onApplyLight, onEdit, onClone, onDelete @@ -205,9 +209,10 @@ const ThemeCard: React.FC = ({ definition.yellow, definition.red ].filter(Boolean); + const isAssigned = isDarkTheme || isLightTheme; return ( -
+
{previewColors.map((color, i) => ( @@ -227,15 +232,29 @@ const ThemeCard: React.FC = ({ {theme.is_builtin === 1 && ( {t('theme_management.built_in')} )} + {isAssigned && ( +
+ {isDarkTheme && {t('theme_management.assigned_dark')}} + {isLightTheme && {t('theme_management.assigned_light')}} +
+ )}
+ + {canWrite && !theme.is_builtin && ( diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx index 030cdaaec..bc2c1bec9 100644 --- a/src/components/SettingsTab.tsx +++ b/src/components/SettingsTab.tsx @@ -18,7 +18,7 @@ import FirmwareUpdateSection from './configuration/FirmwareUpdateSection'; import ChannelDatabaseSection from './configuration/ChannelDatabaseSection'; import { CustomThemeManagement } from './CustomThemeManagement'; import { CustomTilesetManager } from './CustomTilesetManager'; -import { type Theme, type NodeHopsCalculation, useSettings } from '../contexts/SettingsContext'; +import { type Theme, type AppearanceMode, type NodeHopsCalculation, useSettings } from '../contexts/SettingsContext'; import { type SortOption as DashboardSortOption } from './Dashboard/types'; import { useUI } from '../contexts/UIContext'; import { LanguageSelector } from './LanguageSelector'; @@ -121,7 +121,6 @@ const SettingsTab: React.FC = ({ mapTileset, mapPinStyle, iconStyle, - theme, language, solarMonitoringEnabled, solarMonitoringLatitude, @@ -147,7 +146,6 @@ const SettingsTab: React.FC = ({ onMapTilesetChange, onMapPinStyleChange, onIconStyleChange, - onThemeChange, onLanguageChange, onSolarMonitoringEnabledChange, onSolarMonitoringLatitudeChange, @@ -188,6 +186,12 @@ const SettingsTab: React.FC = ({ setDefaultMapCenterZoom, defaultLandingPage, setDefaultLandingPage, + appearanceMode, + setAppearanceMode, + darkTheme, + setDarkTheme, + lightTheme, + setLightTheme, } = useSettings(); const { data: availableSources = [] } = useDashboardSources(); const { showIncompleteNodes, setShowIncompleteNodes } = useUI(); @@ -214,7 +218,9 @@ const SettingsTab: React.FC = ({ const [localDefaultMapCenterLon, setLocalDefaultMapCenterLon] = useState(defaultMapCenterLon); const [localDefaultMapCenterZoom, setLocalDefaultMapCenterZoom] = useState(defaultMapCenterZoom); const [localDefaultLandingPage, setLocalDefaultLandingPage] = useState(defaultLandingPage); - const [localTheme, setLocalTheme] = useState(theme); + const [localAppearanceMode, setLocalAppearanceMode] = useState(appearanceMode); + const [localDarkTheme, setLocalDarkTheme] = useState(darkTheme); + const [localLightTheme, setLocalLightTheme] = useState(lightTheme); const [localNodeHopsCalculation, setLocalNodeHopsCalculation] = useState(nodeHopsCalculation); const [localDashboardSortOption, setLocalDashboardSortOption] = useState(preferredDashboardSortOption); const [localPacketLogEnabled, setLocalPacketLogEnabled] = useState(false); @@ -385,7 +391,9 @@ const SettingsTab: React.FC = ({ setLocalDefaultMapCenterLon(defaultMapCenterLon); setLocalDefaultMapCenterZoom(defaultMapCenterZoom); setLocalDefaultLandingPage(defaultLandingPage); - setLocalTheme(theme); + setLocalAppearanceMode(appearanceMode); + setLocalDarkTheme(darkTheme); + setLocalLightTheme(lightTheme); setLocalNodeHopsCalculation(nodeHopsCalculation); setLocalDashboardSortOption(preferredDashboardSortOption); setLocalSolarMonitoringEnabled(solarMonitoringEnabled); @@ -394,7 +402,7 @@ const SettingsTab: React.FC = ({ setLocalSolarMonitoringAzimuth(solarMonitoringAzimuth); setLocalSolarMonitoringDeclination(solarMonitoringDeclination); setLocalHideIncompleteNodes(!showIncompleteNodes); - }, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, nodeHopsCalculation, preferredDashboardSortOption, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage]); + }, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, nodeHopsCalculation, preferredDashboardSortOption, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme]); // Default solar monitoring lat/long to device position if still at 0 useEffect(() => { @@ -443,7 +451,9 @@ const SettingsTab: React.FC = ({ localDefaultMapCenterLon !== defaultMapCenterLon || localDefaultMapCenterZoom !== defaultMapCenterZoom || localDefaultLandingPage !== defaultLandingPage || - localTheme !== theme || + localAppearanceMode !== appearanceMode || + localDarkTheme !== darkTheme || + localLightTheme !== lightTheme || localNodeHopsCalculation !== nodeHopsCalculation || localDashboardSortOption !== preferredDashboardSortOption || localPacketLogEnabled !== initialPacketMonitorSettings.enabled || @@ -465,8 +475,8 @@ const SettingsTab: React.FC = ({ JSON.stringify(localAnalyticsConfig) !== initialAnalyticsConfig || localAppriseApiServerUrl !== initialAppriseApiServerUrl; setHasChanges(changed); - }, [localMaxNodeAge, localInactiveNodeThresholdHours, localInactiveNodeCheckIntervalMinutes, localInactiveNodeCooldownHours, localTemperatureUnit, localDistanceUnit, localPositionHistoryLineStyle, localTelemetryHours, localFavoriteTelemetryStorageDays, localPreferredSortField, localPreferredSortDirection, localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localTheme, localNodeHopsCalculation, localDashboardSortOption, - maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, theme, nodeHopsCalculation, preferredDashboardSortOption, + }, [localMaxNodeAge, localInactiveNodeThresholdHours, localInactiveNodeCheckIntervalMinutes, localInactiveNodeCooldownHours, localTemperatureUnit, localDistanceUnit, localPositionHistoryLineStyle, localTelemetryHours, localFavoriteTelemetryStorageDays, localPreferredSortField, localPreferredSortDirection, localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localAppearanceMode, localDarkTheme, localLightTheme, localNodeHopsCalculation, localDashboardSortOption, + maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme, nodeHopsCalculation, preferredDashboardSortOption, localPacketLogEnabled, localPacketLogMaxCount, localPacketLogMaxAgeHours, initialPacketMonitorSettings, localSolarMonitoringEnabled, localSolarMonitoringLatitude, localSolarMonitoringLongitude, localSolarMonitoringAzimuth, localSolarMonitoringDeclination, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, @@ -499,7 +509,9 @@ const SettingsTab: React.FC = ({ setLocalDefaultMapCenterLon(defaultMapCenterLon); setLocalDefaultMapCenterZoom(defaultMapCenterZoom); setLocalDefaultLandingPage(defaultLandingPage); - setLocalTheme(theme); + setLocalAppearanceMode(appearanceMode); + setLocalDarkTheme(darkTheme); + setLocalLightTheme(lightTheme); setLocalNodeHopsCalculation(nodeHopsCalculation); setLocalDashboardSortOption(preferredDashboardSortOption); setLocalPacketLogEnabled(initialPacketMonitorSettings.enabled); @@ -523,16 +535,26 @@ const SettingsTab: React.FC = ({ }, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, - dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, theme, nodeHopsCalculation, preferredDashboardSortOption, + dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme, nodeHopsCalculation, preferredDashboardSortOption, initialPacketMonitorSettings, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes, initialHomoglyphEnabled, initialMeshcoreAdvancedPathEdit, initialLocalStatsIntervalMinutes, initialNodeDimmingSettings, setNodeDimmingEnabled, setNodeDimmingStartHours, setNodeDimmingMinOpacity, initialAnalyticsProvider, initialAnalyticsConfig, initialAppriseApiServerUrl]); + const getLocalEffectiveTheme = useCallback((): Theme => { + if (localAppearanceMode === 'dark') return localDarkTheme; + if (localAppearanceMode === 'light') return localLightTheme; + const systemIsDark = typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)').matches + : true; + return systemIsDark ? localDarkTheme : localLightTheme; + }, [localAppearanceMode, localDarkTheme, localLightTheme]); + const handleSave = useCallback(async () => { setIsSaving(true); try { + const localEffectiveTheme = getLocalEffectiveTheme(); const settings = { maxNodeAgeHours: localMaxNodeAge, inactiveNodeThresholdHours: localInactiveNodeThresholdHours, @@ -555,7 +577,10 @@ const SettingsTab: React.FC = ({ defaultMapCenterLon: localDefaultMapCenterLon !== null ? localDefaultMapCenterLon.toString() : '', defaultMapCenterZoom: localDefaultMapCenterZoom !== null ? localDefaultMapCenterZoom.toString() : '', defaultLandingPage: localDefaultLandingPage, - theme: localTheme, + theme: localEffectiveTheme, + appearanceMode: localAppearanceMode, + darkTheme: localDarkTheme, + lightTheme: localLightTheme, packet_log_enabled: localPacketLogEnabled ? '1' : '0', packet_log_max_count: localPacketLogMaxCount.toString(), packet_log_max_age_hours: localPacketLogMaxAgeHours.toString(), @@ -606,7 +631,9 @@ const SettingsTab: React.FC = ({ setDefaultMapCenterLon(localDefaultMapCenterLon); setDefaultMapCenterZoom(localDefaultMapCenterZoom); setDefaultLandingPage(localDefaultLandingPage); - onThemeChange(localTheme); + setAppearanceMode(localAppearanceMode); + setDarkTheme(localDarkTheme); + setLightTheme(localLightTheme); setNodeHopsCalculation(localNodeHopsCalculation); setPreferredDashboardSortOption(localDashboardSortOption); onSolarMonitoringEnabledChange(localSolarMonitoringEnabled); @@ -641,7 +668,7 @@ const SettingsTab: React.FC = ({ localInactiveNodeCheckIntervalMinutes, localInactiveNodeCooldownHours, localTemperatureUnit, localDistanceUnit, localPositionHistoryLineStyle, localTelemetryHours, localFavoriteTelemetryStorageDays, localPreferredSortField, localPreferredSortDirection, - localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localTheme, + localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localAppearanceMode, localDarkTheme, localLightTheme, getLocalEffectiveTheme, localNodeHopsCalculation, localDashboardSortOption, localPacketLogEnabled, localPacketLogMaxCount, localPacketLogMaxAgeHours, localSolarMonitoringEnabled, localSolarMonitoringLatitude, localSolarMonitoringLongitude, localSolarMonitoringAzimuth, localSolarMonitoringDeclination, localHideIncompleteNodes, localHomoglyphEnabled, localLocalStatsIntervalMinutes, @@ -649,7 +676,7 @@ const SettingsTab: React.FC = ({ onInactiveNodeCooldownHoursChange, onTemperatureUnitChange, onDistanceUnitChange, onPositionHistoryLineStyleChange, onTelemetryVisualizationChange, onFavoriteTelemetryStorageDaysChange, onPreferredSortFieldChange, onPreferredSortDirectionChange, onTimeFormatChange, onDateFormatChange, onMapTilesetChange, - onMapPinStyleChange, setNeighborInfoMinZoom, setDefaultMapCenterLat, setDefaultMapCenterLon, setDefaultMapCenterZoom, setDefaultLandingPage, onThemeChange, setNodeHopsCalculation, setPreferredDashboardSortOption, onSolarMonitoringEnabledChange, + onMapPinStyleChange, setNeighborInfoMinZoom, setDefaultMapCenterLat, setDefaultMapCenterLon, setDefaultMapCenterZoom, setDefaultLandingPage, setAppearanceMode, setDarkTheme, setLightTheme, setNodeHopsCalculation, setPreferredDashboardSortOption, onSolarMonitoringEnabledChange, onSolarMonitoringLatitudeChange, onSolarMonitoringLongitudeChange, onSolarMonitoringAzimuthChange, onSolarMonitoringDeclinationChange, setShowIncompleteNodes, showToast, t, nodeDimmingEnabled, nodeDimmingStartHours, nodeDimmingMinOpacity, @@ -771,7 +798,9 @@ const SettingsTab: React.FC = ({ setLocalDateFormat('MM/DD/YYYY'); setLocalMapTileset('osm'); setLocalMapPinStyle('meshmonitor'); - setLocalTheme('mocha'); + setLocalAppearanceMode('system'); + setLocalDarkTheme('mocha'); + setLocalLightTheme('latte'); setLocalNodeHopsCalculation('nodeinfo'); setLocalDashboardSortOption('custom'); setLocalPacketLogEnabled(false); @@ -796,7 +825,9 @@ const SettingsTab: React.FC = ({ onDateFormatChange('MM/DD/YYYY'); onMapTilesetChange('osm'); onMapPinStyleChange('meshmonitor'); - onThemeChange('mocha'); + setAppearanceMode('system'); + setDarkTheme('mocha'); + setLightTheme('latte'); setNodeHopsCalculation('nodeinfo'); setPreferredDashboardSortOption('custom'); onSolarMonitoringEnabledChange(false); @@ -936,6 +967,43 @@ const SettingsTab: React.FC = ({ } }; + const renderThemeOptions = () => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + {customThemes.length > 0 && ( + + {customThemes.map((customTheme) => ( + + ))} + + )} + + ); + return (
@@ -1162,48 +1230,47 @@ const SettingsTab: React.FC = ({ {show('settings-appearance') &&

{t('settings.appearance')}

-
+
+ + +
+
+ +
@@ -2062,4 +2129,4 @@ const SettingsTab: React.FC = ({ ); }; -export default SettingsTab; \ No newline at end of file +export default SettingsTab; diff --git a/src/contexts/SettingsContext.test.tsx b/src/contexts/SettingsContext.test.tsx index d4ab52bd6..9103eaa06 100644 --- a/src/contexts/SettingsContext.test.tsx +++ b/src/contexts/SettingsContext.test.tsx @@ -78,6 +78,37 @@ vi.mock('../utils/temperature', () => ({ const mockFetch = vi.fn(); global.fetch = mockFetch; +let mockSystemIsDark = true; +let mediaQueryListeners: Array<(event: MediaQueryListEvent) => void> = []; + +const installMatchMediaMock = () => { + mediaQueryListeners = []; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query === '(prefers-color-scheme: dark)' ? mockSystemIsDark : false, + media: query, + onchange: null, + addEventListener: (_event: string, listener: (event: MediaQueryListEvent) => void) => { + mediaQueryListeners.push(listener); + }, + removeEventListener: (_event: string, listener: (event: MediaQueryListEvent) => void) => { + mediaQueryListeners = mediaQueryListeners.filter(item => item !== listener); + }, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const setMockSystemAppearance = async (isDark: boolean) => { + mockSystemIsDark = isDark; + await act(async () => { + mediaQueryListeners.forEach(listener => listener({ matches: isDark } as MediaQueryListEvent)); + }); +}; + // Default successful settings response const defaultSettingsResponse = { maxNodeAgeHours: '48', @@ -265,6 +296,8 @@ describe('SettingsContext Types', () => { describe('SettingsProvider', () => { beforeEach(() => { localStorage.clear(); + mockSystemIsDark = true; + installMatchMediaMock(); mockFetch.mockReset(); createFetchMock(); }); @@ -317,6 +350,210 @@ describe('SettingsProvider', () => { expect(contextValue.setTimeFormat).toBeDefined(); }); + it('should default new users to system appearance with mocha dark and latte light themes', async () => { + mockSystemIsDark = false; + installMatchMediaMock(); + mockFetch.mockReset(); + createFetchMock({}); + const { SettingsProvider, useSettings } = await import('./SettingsContext'); + + let contextValue: any; + const Consumer = () => { + contextValue = useSettings(); + return
loaded
; + }; + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(contextValue.isLoading).toBe(false); + }); + + expect(contextValue.appearanceMode).toBe('system'); + expect(contextValue.darkTheme).toBe('mocha'); + expect(contextValue.lightTheme).toBe('latte'); + expect(contextValue.theme).toBe('latte'); + expect(document.documentElement.getAttribute('data-theme')).toBe('latte'); + }); + + it('should migrate legacy mocha users to system appearance with mocha and latte', async () => { + localStorage.setItem('theme', 'mocha'); + mockFetch.mockReset(); + createFetchMock({}); + const { SettingsProvider, useSettings } = await import('./SettingsContext'); + + let contextValue: any; + const Consumer = () => { + contextValue = useSettings(); + return
loaded
; + }; + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(contextValue.isLoading).toBe(false); + }); + + expect(contextValue.appearanceMode).toBe('system'); + expect(contextValue.darkTheme).toBe('mocha'); + expect(contextValue.lightTheme).toBe('latte'); + expect(contextValue.theme).toBe('mocha'); + }); + + it('should migrate legacy non-mocha users to dark mode with both theme slots set to the legacy theme', async () => { + localStorage.setItem('theme', 'dracula'); + mockFetch.mockReset(); + createFetchMock({}); + const { SettingsProvider, useSettings } = await import('./SettingsContext'); + + let contextValue: any; + const Consumer = () => { + contextValue = useSettings(); + return
loaded
; + }; + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(contextValue.isLoading).toBe(false); + }); + + expect(contextValue.appearanceMode).toBe('dark'); + expect(contextValue.darkTheme).toBe('dracula'); + expect(contextValue.lightTheme).toBe('dracula'); + expect(contextValue.theme).toBe('dracula'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dracula'); + }); + + it('should switch effective theme when system appearance changes in system mode', async () => { + mockSystemIsDark = false; + installMatchMediaMock(); + mockFetch.mockReset(); + createFetchMock({}); + const { SettingsProvider, useSettings } = await import('./SettingsContext'); + + let contextValue: any; + const Consumer = () => { + contextValue = useSettings(); + return
loaded
; + }; + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(contextValue.theme).toBe('latte'); + }); + + await setMockSystemAppearance(true); + + await waitFor(() => { + expect(contextValue.theme).toBe('mocha'); + expect(document.documentElement.getAttribute('data-theme')).toBe('mocha'); + }); + }); + + it('should ignore system appearance changes in manual dark and light modes', async () => { + mockFetch.mockReset(); + createFetchMock({}); + const { SettingsProvider, useSettings } = await import('./SettingsContext'); + + let contextValue: any; + const Consumer = () => { + contextValue = useSettings(); + return
loaded
; + }; + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(contextValue.isLoading).toBe(false); + }); + + await act(async () => { + contextValue.setAppearanceMode('dark'); + }); + await setMockSystemAppearance(false); + + await waitFor(() => { + expect(contextValue.theme).toBe('mocha'); + }); + + await act(async () => { + contextValue.setAppearanceMode('light'); + }); + await setMockSystemAppearance(true); + + await waitFor(() => { + expect(contextValue.theme).toBe('latte'); + }); + }); + + it('should allow custom themes in dark and light theme slots', async () => { + mockFetch.mockReset(); + createFetchMock({}); + const { SettingsProvider, useSettings } = await import('./SettingsContext'); + + let contextValue: any; + const Consumer = () => { + contextValue = useSettings(); + return
loaded
; + }; + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(contextValue.isLoading).toBe(false); + }); + + await act(async () => { + contextValue.setDarkTheme('custom-night'); + contextValue.setLightTheme('custom-day'); + }); + + await waitFor(() => { + expect(contextValue.darkTheme).toBe('custom-night'); + expect(contextValue.lightTheme).toBe('custom-day'); + }); + expect(localStorage.getItem('darkTheme')).toBe('custom-night'); + expect(localStorage.getItem('lightTheme')).toBe('custom-day'); + }); + it('should initialize timeFormat from localStorage', async () => { localStorage.setItem('timeFormat', '12'); const { SettingsProvider, useSettings } = await import('./SettingsContext'); diff --git a/src/contexts/SettingsContext.tsx b/src/contexts/SettingsContext.tsx index 0a3c3d85e..6e41635db 100644 --- a/src/contexts/SettingsContext.tsx +++ b/src/contexts/SettingsContext.tsx @@ -30,6 +30,7 @@ export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD'; export type MapPinStyle = 'meshmonitor' | 'official'; export type IconStyle = 'lucide' | 'emoji'; export type NodeHopsCalculation = 'nodeinfo' | 'traceroute' | 'messages'; +export type AppearanceMode = 'system' | 'dark' | 'light'; // Built-in theme types export type BuiltInTheme = @@ -43,6 +44,13 @@ export type BuiltInTheme = // Theme can be a built-in theme or a custom theme slug export type Theme = BuiltInTheme | string; +interface ThemePreferences { + appearanceMode: AppearanceMode; + darkTheme: Theme; + lightTheme: Theme; + effectiveTheme: Theme; +} + // Custom theme definition from the API export interface CustomTheme { id: number; @@ -82,6 +90,9 @@ interface SettingsContextType { defaultMapCenterZoom: number | null; defaultLandingPage: string; theme: Theme; + appearanceMode: AppearanceMode; + darkTheme: Theme; + lightTheme: Theme; language: string; customThemes: CustomTheme[]; customTilesets: CustomTileset[]; @@ -124,6 +135,9 @@ interface SettingsContextType { setDefaultMapCenterZoom: (zoom: number | null) => void; setDefaultLandingPage: (value: string) => void; setTheme: (theme: Theme) => void; + setAppearanceMode: (mode: AppearanceMode) => void; + setDarkTheme: (theme: Theme) => void; + setLightTheme: (theme: Theme) => void; setLanguage: (language: string) => void; loadCustomThemes: () => Promise; addCustomTileset: (tileset: Omit) => Promise; @@ -176,10 +190,62 @@ const detectBaseUrlFromLocation = (): string => { return baseSegments.length > 0 ? '/' + baseSegments.join('/') : ''; }; +const DEFAULT_DARK_THEME: Theme = 'mocha'; +const DEFAULT_LIGHT_THEME: Theme = 'latte'; +const BUILT_IN_THEMES: BuiltInTheme[] = [ + 'mocha', 'macchiato', 'frappe', 'latte', + 'nord', 'dracula', + 'solarized-dark', 'solarized-light', + 'gruvbox-dark', 'gruvbox-light', + 'high-contrast-dark', 'high-contrast-light', + 'protanopia', 'deuteranopia', 'tritanopia' +]; + +const isAppearanceMode = (value: string | null): value is AppearanceMode => ( + value === 'system' || value === 'dark' || value === 'light' +); + +const prefersDarkMode = (): boolean => { + if (typeof window === 'undefined' || !window.matchMedia) return true; + return window.matchMedia('(prefers-color-scheme: dark)').matches; +}; + +const getEffectiveTheme = (mode: AppearanceMode, darkTheme: Theme, lightTheme: Theme, systemIsDark: boolean): Theme => { + if (mode === 'dark') return darkTheme; + if (mode === 'light') return lightTheme; + return systemIsDark ? darkTheme : lightTheme; +}; + +const getInitialThemePreferences = (): ThemePreferences => { + const storedMode = localStorage.getItem('appearanceMode'); + const storedDarkTheme = localStorage.getItem('darkTheme'); + const storedLightTheme = localStorage.getItem('lightTheme'); + const legacyTheme = localStorage.getItem('theme'); + + const hasNewPreferences = isAppearanceMode(storedMode) || storedDarkTheme || storedLightTheme; + const darkTheme = storedDarkTheme || (hasNewPreferences ? DEFAULT_DARK_THEME : legacyTheme || DEFAULT_DARK_THEME); + const lightTheme = storedLightTheme || (hasNewPreferences ? DEFAULT_LIGHT_THEME : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME ? legacyTheme : DEFAULT_LIGHT_THEME); + const appearanceMode = isAppearanceMode(storedMode) + ? storedMode + : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME + ? 'dark' + : 'system'; + const effectiveTheme = getEffectiveTheme(appearanceMode, darkTheme, lightTheme, prefersDarkMode()); + + localStorage.setItem('appearanceMode', appearanceMode); + localStorage.setItem('darkTheme', darkTheme); + localStorage.setItem('lightTheme', lightTheme); + localStorage.setItem('theme', effectiveTheme); + + return { appearanceMode, darkTheme, lightTheme, effectiveTheme }; +}; + export const SettingsProvider: React.FC = ({ children, baseUrl: baseUrlProp }) => { const baseUrl = baseUrlProp ?? detectBaseUrlFromLocation(); const { getToken: getCsrfToken } = useCsrf(); const [isLoading, setIsLoading] = useState(true); + const [initialThemePreferences] = useState(() => getInitialThemePreferences()); + const [systemIsDark, setSystemIsDark] = useState(() => prefersDarkMode()); const [maxNodeAgeHours, setMaxNodeAgeHoursState] = useState(() => { const saved = localStorage.getItem('maxNodeAgeHours'); @@ -304,16 +370,19 @@ export const SettingsProvider: React.FC = ({ children, ba }); const [theme, setThemeState] = useState(() => { - const saved = localStorage.getItem('theme'); - const validThemes: Theme[] = [ - 'mocha', 'macchiato', 'frappe', 'latte', - 'nord', 'dracula', - 'solarized-dark', 'solarized-light', - 'gruvbox-dark', 'gruvbox-light', - 'high-contrast-dark', 'high-contrast-light', - 'protanopia', 'deuteranopia', 'tritanopia' - ]; - return (saved && validThemes.includes(saved as Theme) ? saved : 'mocha') as Theme; + return initialThemePreferences.effectiveTheme; + }); + + const [appearanceMode, setAppearanceModeState] = useState(() => { + return initialThemePreferences.appearanceMode; + }); + + const [darkTheme, setDarkThemeState] = useState(() => { + return initialThemePreferences.darkTheme; + }); + + const [lightTheme, setLightThemeState] = useState(() => { + return initialThemePreferences.lightTheme; }); const [language, setLanguageState] = useState(() => { @@ -629,25 +698,13 @@ export const SettingsProvider: React.FC = ({ children, ba } }, [customThemes]); - /** - * Built-in theme names for validation - */ - const builtInThemes: BuiltInTheme[] = [ - 'mocha', 'macchiato', 'frappe', 'latte', - 'nord', 'dracula', - 'solarized-dark', 'solarized-light', - 'gruvbox-dark', 'gruvbox-light', - 'high-contrast-dark', 'high-contrast-light', - 'protanopia', 'deuteranopia', 'tritanopia' - ]; - - const setTheme = (newTheme: Theme) => { - logger.debug(`🔄 setTheme called with: ${newTheme}`); + const applyTheme = React.useCallback((newTheme: Theme) => { + logger.debug(`🔄 applyTheme called with: ${newTheme}`); setThemeState(newTheme); localStorage.setItem('theme', newTheme); // Check if this is a built-in or custom theme - const isBuiltIn = builtInThemes.includes(newTheme as BuiltInTheme); + const isBuiltIn = BUILT_IN_THEMES.includes(newTheme as BuiltInTheme); logger.debug(`📝 Is built-in theme: ${isBuiltIn}`); if (isBuiltIn) { @@ -667,7 +724,41 @@ export const SettingsProvider: React.FC = ({ children, ba // Apply the custom theme CSS applyCustomThemeCSS(newTheme); } - }; + }, [applyCustomThemeCSS]); + + const applyAppearancePreferences = React.useCallback(( + mode: AppearanceMode, + nextDarkTheme: Theme, + nextLightTheme: Theme, + nextSystemIsDark = systemIsDark + ) => { + applyTheme(getEffectiveTheme(mode, nextDarkTheme, nextLightTheme, nextSystemIsDark)); + }, [applyTheme, systemIsDark]); + + const setAppearanceMode = React.useCallback((mode: AppearanceMode) => { + setAppearanceModeState(mode); + localStorage.setItem('appearanceMode', mode); + }, []); + + const setDarkTheme = React.useCallback((newTheme: Theme) => { + setDarkThemeState(newTheme); + localStorage.setItem('darkTheme', newTheme); + }, []); + + const setLightTheme = React.useCallback((newTheme: Theme) => { + setLightThemeState(newTheme); + localStorage.setItem('lightTheme', newTheme); + }, []); + + const setTheme = React.useCallback((newTheme: Theme) => { + setAppearanceModeState('dark'); + setDarkThemeState(newTheme); + setLightThemeState(newTheme); + localStorage.setItem('appearanceMode', 'dark'); + localStorage.setItem('darkTheme', newTheme); + localStorage.setItem('lightTheme', newTheme); + applyTheme(newTheme); + }, [applyTheme]); const setLanguage = async (lang: string) => { setLanguageState(lang); @@ -1140,21 +1231,40 @@ export const SettingsProvider: React.FC = ({ children, ba localStorage.setItem('defaultLandingPage', settings.defaultLandingPage); } - if (settings.theme) { - // Accept any theme (built-in or custom) - setThemeState(settings.theme as Theme); - localStorage.setItem('theme', settings.theme); - - // Check if it's a built-in or custom theme - const isBuiltIn = builtInThemes.includes(settings.theme as BuiltInTheme); - - if (isBuiltIn) { - document.documentElement.setAttribute('data-theme', settings.theme); - } else { - // Custom theme will be applied after custom themes are loaded - document.documentElement.setAttribute('data-theme', 'custom'); - logger.debug(`🎨 Custom theme '${settings.theme}' will be applied after themes load`); - } + if ( + typeof settings.theme === 'string' || + typeof settings.appearanceMode === 'string' || + typeof settings.darkTheme === 'string' || + typeof settings.lightTheme === 'string' + ) { + const hasNewThemePreferences = ( + isAppearanceMode(settings.appearanceMode) || + typeof settings.darkTheme === 'string' || + typeof settings.lightTheme === 'string' + ); + const legacyTheme = typeof settings.theme === 'string' ? settings.theme as Theme : null; + const nextAppearanceMode: AppearanceMode = isAppearanceMode(settings.appearanceMode) + ? settings.appearanceMode + : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME && !hasNewThemePreferences + ? 'dark' + : 'system'; + const nextDarkTheme: Theme = typeof settings.darkTheme === 'string' + ? settings.darkTheme + : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME && !hasNewThemePreferences + ? legacyTheme + : DEFAULT_DARK_THEME; + const nextLightTheme: Theme = typeof settings.lightTheme === 'string' + ? settings.lightTheme + : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME && !hasNewThemePreferences + ? legacyTheme + : DEFAULT_LIGHT_THEME; + + setAppearanceModeState(nextAppearanceMode); + setDarkThemeState(nextDarkTheme); + setLightThemeState(nextLightTheme); + localStorage.setItem('appearanceMode', nextAppearanceMode); + localStorage.setItem('darkTheme', nextDarkTheme); + localStorage.setItem('lightTheme', nextLightTheme); } if (settings.language) { @@ -1295,6 +1405,24 @@ export const SettingsProvider: React.FC = ({ children, ba loadCustomThemes(); }, [loadCustomThemes]); + React.useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (event: MediaQueryListEvent) => { + setSystemIsDark(event.matches); + }; + + setSystemIsDark(mediaQuery.matches); + mediaQuery.addEventListener('change', handleChange); + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + React.useEffect(() => { + applyAppearancePreferences(appearanceMode, darkTheme, lightTheme, systemIsDark); + }, [appearanceMode, applyAppearancePreferences, darkTheme, lightTheme, systemIsDark]); + // Load mute preferences on mount (server-side, requires auth) React.useEffect(() => { loadMutePreferences(); @@ -1304,7 +1432,7 @@ export const SettingsProvider: React.FC = ({ children, ba React.useEffect(() => { logger.debug(`🔄 useEffect triggered - customThemes: ${customThemes.length}, theme: ${theme}`); if (customThemes.length > 0 && theme) { - const isBuiltIn = builtInThemes.includes(theme as BuiltInTheme); + const isBuiltIn = BUILT_IN_THEMES.includes(theme as BuiltInTheme); logger.debug(`📝 useEffect - Is built-in: ${isBuiltIn}`); if (!isBuiltIn) { @@ -1342,6 +1470,9 @@ export const SettingsProvider: React.FC = ({ children, ba defaultMapCenterZoom, defaultLandingPage, theme, + appearanceMode, + darkTheme, + lightTheme, language, customThemes, customTilesets, @@ -1384,6 +1515,9 @@ export const SettingsProvider: React.FC = ({ children, ba setDefaultMapCenterZoom, setDefaultLandingPage, setTheme, + setAppearanceMode, + setDarkTheme, + setLightTheme, setLanguage, loadCustomThemes, addCustomTileset, @@ -1436,6 +1570,9 @@ export const useDisplaySettings = () => { dateFormat: s.dateFormat, setDateFormat: s.setDateFormat, language: s.language, setLanguage: s.setLanguage, theme: s.theme, setTheme: s.setTheme, + appearanceMode: s.appearanceMode, setAppearanceMode: s.setAppearanceMode, + darkTheme: s.darkTheme, setDarkTheme: s.setDarkTheme, + lightTheme: s.lightTheme, setLightTheme: s.setLightTheme, customThemes: s.customThemes, isLoadingThemes: s.isLoadingThemes, loadCustomThemes: s.loadCustomThemes, }; }; diff --git a/src/server/constants/settings.ts b/src/server/constants/settings.ts index 545032a8d..2be4bb8b2 100644 --- a/src/server/constants/settings.ts +++ b/src/server/constants/settings.ts @@ -80,6 +80,9 @@ export const VALID_SETTINGS_KEYS = [ 'mapPinStyle', 'favoriteTelemetryStorageDays', 'theme', + 'appearanceMode', + 'darkTheme', + 'lightTheme', 'customTilesets', 'hideIncompleteNodes', 'inactiveNodeThresholdHours',