diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 1771afbdb0d..f653bc37b27 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -298,6 +298,9 @@ "blocking_mode_nxdomain": "NXDOMAIN: Respond with NXDOMAIN code", "blocking_mode_null_ip": "Null IP: Respond with zero IP address (0.0.0.0 for A; :: for AAAA)", "blocking_mode_custom_ip": "Custom IP: Respond with a manually set IP address", + "theme_auto": "Auto", + "theme_light": "Light", + "theme_dark": "Dark", "upstream_dns_client_desc": "If you keep this field empty, AdGuard Home will use the servers configured in the <0>DNS settings0>.", "tracker_source": "Tracker source", "source_label": "Source", diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 2242490b944..d86ea43efee 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -363,18 +363,18 @@ export const changeLanguage = (lang) => async (dispatch) => { } }; -export const getLanguageRequest = createAction('GET_LANGUAGE_REQUEST'); -export const getLanguageFailure = createAction('GET_LANGUAGE_FAILURE'); -export const getLanguageSuccess = createAction('GET_LANGUAGE_SUCCESS'); +export const changeThemeRequest = createAction('CHANGE_THEME_REQUEST'); +export const changeThemeFailure = createAction('CHANGE_THEME_FAILURE'); +export const changeThemeSuccess = createAction('CHANGE_THEME_SUCCESS'); -export const getLanguage = () => async (dispatch) => { - dispatch(getLanguageRequest()); +export const changeTheme = (theme) => async (dispatch) => { + dispatch(changeThemeRequest()); try { - const langSettings = await apiClient.getCurrentLanguage(); - dispatch(getLanguageSuccess(langSettings.language)); + await apiClient.changeTheme({ theme }); + dispatch(changeThemeSuccess({ theme })); } catch (error) { dispatch(addErrorToast({ error })); - dispatch(getLanguageFailure()); + dispatch(changeThemeFailure()); } }; diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 06f40c6c479..d984bbb8b0f 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -1,8 +1,12 @@ import axios from 'axios'; import { getPathWithQueryString } from '../helpers/helpers'; -import { QUERY_LOGS_PAGE_LIMIT, HTML_PAGES, R_PATH_LAST_PART } from '../helpers/constants'; +import { + QUERY_LOGS_PAGE_LIMIT, HTML_PAGES, R_PATH_LAST_PART, THEMES, +} from '../helpers/constants'; import { BASE_URL } from '../../constants'; +import i18n from '../i18n'; +import { LANGUAGES } from '../helpers/twosky'; class Api { baseUrl = BASE_URL; @@ -224,21 +228,21 @@ class Api { } // Language - CURRENT_LANGUAGE = { path: 'i18n/current_language', method: 'GET' }; - CHANGE_LANGUAGE = { path: 'i18n/change_language', method: 'POST' }; + async changeLanguage(config) { + const profile = await this.getProfile(); + profile.language = config.language; - getCurrentLanguage() { - const { path, method } = this.CURRENT_LANGUAGE; - return this.makeRequest(path, method); + return this.setProfile(profile); } - changeLanguage(config) { - const { path, method } = this.CHANGE_LANGUAGE; - const parameters = { - data: config, - }; - return this.makeRequest(path, method, parameters); + // Theme + + async changeTheme(config) { + const profile = await this.getProfile(); + profile.theme = config.theme; + + return this.setProfile(profile); } // DHCP @@ -571,11 +575,24 @@ class Api { // Profile GET_PROFILE = { path: 'profile', method: 'GET' }; + UPDATE_PROFILE = { path: 'profile/update', method: 'PUT' }; + getProfile() { const { path, method } = this.GET_PROFILE; return this.makeRequest(path, method); } + setProfile(data) { + const theme = data.theme ? data.theme : THEMES.auto; + const defaultLanguage = i18n.language ? i18n.language : LANGUAGES.en; + const language = data.language ? data.language : defaultLanguage; + + const { path, method } = this.UPDATE_PROFILE; + const config = { data: { theme, language } }; + + return this.makeRequest(path, method, config); + } + // DNS config GET_DNS_CONFIG = { path: 'dns_info', method: 'GET' }; diff --git a/client/src/components/App/index.css b/client/src/components/App/index.css index fa8ca78899b..751a8e2239e 100644 --- a/client/src/components/App/index.css +++ b/client/src/components/App/index.css @@ -1,4 +1,26 @@ :root { + --bgcolor: #f5f7fb; + --mcolor: #495057; + --scolor: rgba(74, 74, 74, 0.7); + --border-color: rgba(0, 40, 100, 0.12); + --header-bgcolor: #fff; + --card-bgcolor: #fff; + --card-border-color: rgba(0, 40, 100, 0.12); + --ctrl-bgcolor: #fff; + --ctrl-select-bgcolor: rgba(69, 79, 94, 0.12); + --ctrl-dropdown-color: #212529; + --ctrl-dropdown-bgcolor-focus: #f8f9fa; + --ctrl-dropdown-color-focus: #16181b; + --btn-success-bgcolor: #5eba00; + --form-disabled-bgcolor: #f8f9fa; + --form-disabled-color: #495057; + --rt-nodata-bgcolor: rgba(255,255,255,0.8); + --rt-nodata-color: rgba(0,0,0,0.5); + --modal-overlay-bgcolor: rgba(255, 255, 255, 0.75); + --logs__table-bgcolor: #fff; + --logs__row--blue-bgcolor: #e5effd; + --logs__row--white-bgcolor: #fff; + --detailed-info-color: #888888; --yellow-pale: rgba(247, 181, 0, 0.1); --green79: #67b279; --gray-a5: #a5a5a5; @@ -8,6 +30,32 @@ --font-size-disable-autozoom: 1rem; } +[data-theme="dark"] { + --bgcolor: #131313; + --mcolor: #e6e6e6; + --scolor: #a5a5a5; + --header-bgcolor: #131313; + --border-color: #222; + --card-bgcolor: #1c1c1c; + --card-border-color: #3d3d3d; + --ctrl-bgcolor: #1c1c1c; + --ctrl-select-bgcolor: #3d3d3d; + --ctrl-dropdown-color: #fff; + --ctrl-dropdown-bgcolor-focus: #000; + --ctrl-dropdown-color-focus: #fff; + --btn-success-bgcolor: #67b279; + --form-disabled-bgcolor: #3d3d3d; + --form-disabled-color: #a5a5a5; + --logs__text-color: #f3f3f3; + --rt-nodata-bgcolor: #1c1c1c; + --rt-nodata-color: #fff; + --modal-overlay-bgcolor: #1c1c1c; + --logs__table-bgcolor: #3d3d3d; + --logs__row--blue-bgcolor: #467fcf; + --logs__row--white-bgcolor: #1c1c1c; + --detailed-info-color: #fff; +} + body { margin: 0; padding: 0; diff --git a/client/src/components/App/index.js b/client/src/components/App/index.js index 6d65ccb8cc4..819bb0c6902 100644 --- a/client/src/components/App/index.js +++ b/client/src/components/App/index.js @@ -20,8 +20,13 @@ import EncryptionTopline from '../ui/EncryptionTopline'; import Icons from '../ui/Icons'; import i18n from '../../i18n'; import Loading from '../ui/Loading'; -import { FILTERS_URLS, MENU_URLS, SETTINGS_URLS } from '../../helpers/constants'; -import { getLogsUrlParams, setHtmlLangAttr } from '../../helpers/helpers'; +import { + FILTERS_URLS, + MENU_URLS, + SETTINGS_URLS, + THEMES, +} from '../../helpers/constants'; +import { getLogsUrlParams, setHtmlLangAttr, setUITheme } from '../../helpers/helpers'; import Header from '../Header'; import { changeLanguage, getDnsStatus } from '../../actions'; @@ -109,6 +114,7 @@ const App = () => { isCoreRunning, isUpdateAvailable, processing, + theme, } = useSelector((state) => state.dashboard, shallowEqual); const { processing: processingEncryption } = useSelector(( @@ -138,6 +144,41 @@ const App = () => { setLanguage(); }, [language]); + const handleAutoTheme = (e, accountTheme) => { + if (accountTheme !== THEMES.auto) { + return; + } + + if (e.matches) { + setUITheme(THEMES.dark); + } else { + setUITheme(THEMES.light); + } + }; + + useEffect(() => { + if (theme !== THEMES.auto) { + setUITheme(theme); + + return; + } + + const colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); + const prefersDark = colorSchemeMedia.matches; + setUITheme(prefersDark ? THEMES.dark : THEMES.light); + + if (colorSchemeMedia.addEventListener !== undefined) { + colorSchemeMedia.addEventListener('change', (e) => { + handleAutoTheme(e, theme); + }); + } else { + // Deprecated addListener for older versions of Safari. + colorSchemeMedia.addListener((e) => { + handleAutoTheme(e, theme); + }); + } + }, [theme]); + const reloadPage = () => { window.location.reload(); }; diff --git a/client/src/components/Header/Header.css b/client/src/components/Header/Header.css index a5fe802ee2b..c5412f7aba5 100644 --- a/client/src/components/Header/Header.css +++ b/client/src/components/Header/Header.css @@ -47,7 +47,7 @@ width: 250px; height: 100vh; transition: transform 0.3s ease; - background-color: #fff; + background-color: var(--header-bgcolor); overflow-y: auto; } diff --git a/client/src/components/Logs/Cells/IconTooltip.css b/client/src/components/Logs/Cells/IconTooltip.css index 245c14d9c3a..8f7eb45388b 100644 --- a/client/src/components/Logs/Cells/IconTooltip.css +++ b/client/src/components/Logs/Cells/IconTooltip.css @@ -4,7 +4,8 @@ box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); border-radius: 4px !important; pointer-events: auto !important; - background-color: var(--white); + background-color: var(--ctrl-bgcolor); + color: var(--scolor); z-index: 102; overflow-y: auto; max-height: 100%; diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css index 8df1d62b12e..358c2a6a7df 100644 --- a/client/src/components/Logs/Logs.css +++ b/client/src/components/Logs/Logs.css @@ -31,7 +31,7 @@ overflow: hidden; font-size: 1rem; font-family: var(--font-family-sans-serif); - color: var(--gray-4d); + color: var(--logs__text-color); letter-spacing: 0; line-height: 1.5rem; } @@ -48,7 +48,7 @@ .detailed-info { font-size: 0.8rem; line-height: 1.4; - color: #888888; + color: var(--detailed-info-color); } .logs__text--link { @@ -369,7 +369,7 @@ /* QUERY_STATUS_COLORS */ .logs__row--blue { - background-color: var(--blue); + background-color: var(--logs__row--blue-bgcolor); } .logs__row--green { @@ -381,7 +381,7 @@ } .logs__row--white { - background-color: var(--white); + background-color: var(--logs__row--white-bgcolor); } .logs__row--yellow { @@ -389,8 +389,8 @@ } .logs__no-data { - color: var(--gray-4d); - background-color: var(--white80); + color: var(--mcolor); + background-color: var(--logs__table-bgcolor); pointer-events: none; font-weight: 600; text-align: center; @@ -403,7 +403,7 @@ } .logs__table { - background-color: var(--white); + background-color: var(--logs__table-bgcolor); border: 0; border-radius: 8px; min-height: 43rem; diff --git a/client/src/components/Settings/Settings.css b/client/src/components/Settings/Settings.css index 3fd560f9f9f..ba6d2462aa5 100644 --- a/client/src/components/Settings/Settings.css +++ b/client/src/components/Settings/Settings.css @@ -77,7 +77,7 @@ .form__desc { margin-top: 10px; font-size: 13px; - color: rgba(74, 74, 74, 0.7); + color: var(--scolor); } .form__desc--top { diff --git a/client/src/components/ui/Checkbox.css b/client/src/components/ui/Checkbox.css index bab88c796e4..2a556fb0faf 100644 --- a/client/src/components/ui/Checkbox.css +++ b/client/src/components/ui/Checkbox.css @@ -107,5 +107,5 @@ .checkbox__label-subtitle { display: block; line-height: 1.5; - color: rgba(74, 74, 74, 0.7); + color: var(--scolor); } diff --git a/client/src/components/ui/Footer.css b/client/src/components/ui/Footer.css index 66fbe5e2095..fd0dca2b535 100644 --- a/client/src/components/ui/Footer.css +++ b/client/src/components/ui/Footer.css @@ -18,6 +18,11 @@ align-items: center; } +.footer__column--theme { + min-width: 220px; + margin-bottom: 0; +} + .footer__column--language { min-width: 220px; margin-bottom: 0; @@ -49,6 +54,11 @@ } .footer__column--language { + min-width: initial; + margin-left: 20px; + } + + .footer__column--theme { min-width: initial; margin-left: auto; } diff --git a/client/src/components/ui/Footer.js b/client/src/components/ui/Footer.js index 393e16fe8cc..c1d1b40e5d1 100644 --- a/client/src/components/ui/Footer.js +++ b/client/src/components/ui/Footer.js @@ -1,8 +1,9 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; import classNames from 'classnames'; -import { REPOSITORY, PRIVACY_POLICY_LINK } from '../../helpers/constants'; +import { REPOSITORY, PRIVACY_POLICY_LINK, THEMES } from '../../helpers/constants'; import { LANGUAGES } from '../../helpers/twosky'; import i18n from '../../i18n'; @@ -10,6 +11,7 @@ import Version from './Version'; import './Footer.css'; import './Select.css'; import { setHtmlLangAttr } from '../../helpers/helpers'; +import { changeTheme } from '../../actions'; const linksData = [ { @@ -29,6 +31,11 @@ const linksData = [ const Footer = () => { const { t } = useTranslation(); + const dispatch = useDispatch(); + + const currentTheme = useSelector((state) => (state.dashboard ? state.dashboard.theme : 'auto')); + const profileName = useSelector((state) => (state.dashboard ? state.dashboard.name : '')); + const isLoggedIn = profileName !== ''; const getYear = () => { const today = new Date(); @@ -41,6 +48,11 @@ const Footer = () => { setHtmlLangAttr(value); }; + const onThemeChanged = (event) => { + const { value } = event.target; + dispatch(changeTheme(value)); + }; + const renderCopyright = () =>