diff --git a/CHANGELOG.md b/CHANGELOG.md index b9880d0d..1c39495e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#379](https://github.com/shlinkio/shlink-web-client/issues/379) and [#384](https://github.com/shlinkio/shlink-web-client/issues/384) Improved QR code modal, including controls to customize size, format and margin, as well as a button to copy the link to the clipboard. * [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default. * [#386](https://github.com/shlinkio/shlink-web-client/issues/386) Added new card in overview section to display amount of orphan visits when using Shlink 2.6.0 or higher. +* [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme. ### Changed * *Nothing* diff --git a/src/App.tsx b/src/App.tsx index 4928f42d..23938617 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,11 +2,14 @@ import { useEffect, FC } from 'react'; import { Route, Switch } from 'react-router-dom'; import NotFound from './common/NotFound'; import { ServersMap } from './servers/data'; +import { Settings } from './settings/reducers/settings'; +import { changeThemeInMarkup } from './utils/theme'; import './App.scss'; interface AppProps { fetchServers: Function; servers: ServersMap; + settings: Settings; } const App = ( @@ -17,12 +20,14 @@ const App = ( EditServer: FC, Settings: FC, ShlinkVersionsContainer: FC, -) => ({ fetchServers, servers }: AppProps) => { - // On first load, try to fetch the remote servers if the list is empty +) => ({ fetchServers, servers, settings }: AppProps) => { useEffect(() => { + // On first load, try to fetch the remote servers if the list is empty if (Object.keys(servers).length === 0) { fetchServers(); } + + changeThemeInMarkup(settings.ui?.theme ?? 'light'); }, []); return ( diff --git a/src/common/AsideMenu.scss b/src/common/AsideMenu.scss index 09e0dc3e..35b0339e 100644 --- a/src/common/AsideMenu.scss +++ b/src/common/AsideMenu.scss @@ -3,7 +3,7 @@ .aside-menu { width: $asideMenuWidth; - background-color: white; + background-color: var(--primary-color); box-shadow: rgba(0, 0, 0, .05) 0 8px 15px; position: fixed !important; padding-top: 13px; @@ -18,7 +18,6 @@ @media (min-width: $mdMin) { padding: 30px 15px 15px; - border-right: 1px solid #eeeeee; } @media (max-width: $smMax) { @@ -50,17 +49,13 @@ } .aside-menu__item:hover { - background-color: $lightColor; -} - -.aside-menu__item--selected { - color: #ffffff; - background-color: $mainColor; + background-color: var(--secondary-color); } +.aside-menu__item--selected, .aside-menu__item--selected:hover { color: #ffffff; - background-color: $mainColor; + background-color: var(--brand-color); } .aside-menu__item--divider { diff --git a/src/common/Home.scss b/src/common/Home.scss index 775de88f..c8a00d35 100644 --- a/src/common/Home.scss +++ b/src/common/Home.scss @@ -36,6 +36,6 @@ .home__servers-container { @media (min-width: $mdMin) { - border-left: 1px solid rgba(0, 0, 0, .125); + border-left: 1px solid var(--border-color); } } diff --git a/src/common/MainHeader.scss b/src/common/MainHeader.scss index 89279c68..03d9ce80 100644 --- a/src/common/MainHeader.scss +++ b/src/common/MainHeader.scss @@ -1,8 +1,8 @@ @import '../utils/base'; .main-header.main-header { - background-color: $mainColor !important; color: white; + background-color: var(--brand-color) !important; .navbar-brand { color: inherit !important; diff --git a/src/common/react-tagsinput.scss b/src/common/react-tagsinput.scss index 6753a0f9..6ecd1cd3 100644 --- a/src/common/react-tagsinput.scss +++ b/src/common/react-tagsinput.scss @@ -1,6 +1,8 @@ +@import '../utils/base'; + .react-tagsinput { - background-color: #ffffff; - border: 1px solid #cccccc; + background-color: var(--input-color); + border: 1px solid var(--input-border-color); border-radius: .25rem; overflow: hidden; min-height: 2.6rem; @@ -10,7 +12,7 @@ .react-tagsinput--focused { border-color: #80bdff; - box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25); + box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%); } .react-tagsinput-tag { @@ -44,5 +46,13 @@ width: 100%; margin-bottom: 6px; font-size: 1.25rem; - color: #495057; + color: var(--input-text-color); +} + +.react-tagsinput-input::placeholder { + color: $textPlaceholder; +} + +.react-autosuggest__suggestion--highlighted { + background-color: var(--active-color); } diff --git a/src/container/index.ts b/src/container/index.ts index f7a0d3cf..b369c1f8 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -43,7 +43,7 @@ bottle.serviceFactory( 'Settings', 'ShlinkVersionsContainer', ); -bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ])); +bottle.decorator('App', connect([ 'servers', 'settings' ], [ 'fetchServers' ])); provideCommonServices(bottle, connect, withRouter); provideApiServices(bottle); diff --git a/src/domains/DomainSelector.scss b/src/domains/DomainSelector.scss index 89e02433..729f58ea 100644 --- a/src/domains/DomainSelector.scss +++ b/src/domains/DomainSelector.scss @@ -1,12 +1,19 @@ +@import '../utils/base'; @import '../utils/mixins/vertical-align'; +.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn, +.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover, +.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active { + color: $textPlaceholder !important; +} + .domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active, .domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover, .domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active { - color: #495057 !important; + color: var(--input-text-color) !important; } .domains-dropdown__back-btn.domains-dropdown__back-btn, .domains-dropdown__back-btn.domains-dropdown__back-btn:hover { - border-color: #ced4da; + border-color: var(--border-color); } diff --git a/src/domains/DomainSelector.tsx b/src/domains/DomainSelector.tsx index c843f409..5ab1e591 100644 --- a/src/domains/DomainSelector.tsx +++ b/src/domains/DomainSelector.tsx @@ -54,7 +54,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do ) : ( {domains.map(({ domain, isDefault }) => ( th, +.table-active > td { + background-color: var(--table-highlight-color) !important; } .navbar-brand { @@ -74,10 +158,6 @@ body, } } -.pagination .page-link { - cursor: pointer; -} - .indivisible { white-space: nowrap; } @@ -92,14 +172,6 @@ body, white-space: nowrap; } -.react-datepicker__day--keyboard-selected { - background-color: $mainColor; - - &:hover { - background-color: darken($mainColor, 12%); - } -} - .progress-bar { background-color: $mainColor; } diff --git a/src/servers/Overview.scss b/src/servers/Overview.scss index 75a56ec4..f8b49ad7 100644 --- a/src/servers/Overview.scss +++ b/src/servers/Overview.scss @@ -2,10 +2,10 @@ .overview__card.overview__card { text-align: center; - border-top: 3px solid $mainColor; + border-top: 3px solid var(--brand-color); } .overview__card-title { text-transform: uppercase; - color: #6c757d; + color: $textPlaceholder; } diff --git a/src/servers/ServersListGroup.scss b/src/servers/ServersListGroup.scss index 78457102..9a0709f0 100644 --- a/src/servers/ServersListGroup.scss +++ b/src/servers/ServersListGroup.scss @@ -18,7 +18,7 @@ } .servers-list__server-item:hover { - background-color: $lightColor; + background-color: var(--secondary-color); } .servers-list__server-item-icon { @@ -29,7 +29,7 @@ .servers-list__list-group--embedded.servers-list__list-group--embedded { border-radius: 0; - border-top: 1px solid rgba(0, 0, 0, .125); + border-top: 1px solid var(--border-color); @media (min-width: $mdMin) { max-height: 220px; @@ -40,6 +40,6 @@ .servers-list__server-item { border: none; - border-bottom: 1px solid rgba(0, 0, 0, .125); + border-bottom: 1px solid var(--border-color); } } diff --git a/src/settings/RealTimeUpdates.tsx b/src/settings/RealTimeUpdates.tsx index 914c5a6f..d737f6e9 100644 --- a/src/settings/RealTimeUpdates.tsx +++ b/src/settings/RealTimeUpdates.tsx @@ -19,6 +19,9 @@ const RealTimeUpdates = ( Enable or disable real-time updates, when using Shlink v2.2.0 or newer. + + Real-time updates are currently being {realTimeUpdates.enabled ? 'processed' : 'ignored'}. + diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index bbd524d9..8e6731f5 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -2,14 +2,19 @@ import { FC } from 'react'; import { Row } from 'reactstrap'; import NoMenuLayout from '../common/NoMenuLayout'; -const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC) => () => ( +const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC) => () => (
- +
+ +
+
+ +
- +
diff --git a/src/settings/UserInterface.scss b/src/settings/UserInterface.scss new file mode 100644 index 00000000..121b0784 --- /dev/null +++ b/src/settings/UserInterface.scss @@ -0,0 +1,4 @@ +.user-interface__theme-icon { + float: right; + margin-top: .25rem; +} diff --git a/src/settings/UserInterface.tsx b/src/settings/UserInterface.tsx new file mode 100644 index 00000000..3180ebc4 --- /dev/null +++ b/src/settings/UserInterface.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons'; +import { SimpleCard } from '../utils/SimpleCard'; +import ToggleSwitch from '../utils/ToggleSwitch'; +import { changeThemeInMarkup, Theme } from '../utils/theme'; +import { Settings, UiSettings } from './reducers/settings'; +import './UserInterface.scss'; + +interface UserInterfaceProps { + settings: Settings; + setUiSettings: (settings: UiSettings) => void; +} + +export const UserInterface: FC = ({ settings: { ui }, setUiSettings }) => ( + + + { + const theme: Theme = useDarkTheme ? 'dark' : 'light'; + + setUiSettings({ theme }); + changeThemeInMarkup(theme); + }} + > + Use dark theme. + + +); diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index aa1bc929..e5c0d1f9 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -2,6 +2,7 @@ import { Action } from 'redux'; import { dissoc, mergeDeepRight } from 'ramda'; import { buildReducer } from '../../utils/helpers/redux'; import { RecursivePartial } from '../../utils/utils'; +import { Theme } from '../../utils/theme'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; @@ -19,9 +20,14 @@ export interface ShortUrlCreationSettings { validateUrls: boolean; } +export interface UiSettings { + theme: Theme; +} + export interface Settings { realTimeUpdates: RealTimeUpdatesSettings; shortUrlCreation?: ShortUrlCreationSettings; + ui?: UiSettings; } const initialState: Settings = { @@ -31,6 +37,9 @@ const initialState: Settings = { shortUrlCreation: { validateUrls: false, }, + ui: { + theme: 'light', + }, }; type SettingsAction = Action & Settings; @@ -55,3 +64,8 @@ export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings): type: SET_SETTINGS, shortUrlCreation: settings, }); + +export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({ + type: SET_SETTINGS, + ui: settings, +}); diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts index 393ccefc..cd01599b 100644 --- a/src/settings/services/provideServices.ts +++ b/src/settings/services/provideServices.ts @@ -1,14 +1,20 @@ import Bottle from 'bottlejs'; import RealTimeUpdates from '../RealTimeUpdates'; import Settings from '../Settings'; -import { setRealTimeUpdatesInterval, setShortUrlCreationSettings, toggleRealTimeUpdates } from '../reducers/settings'; +import { + setRealTimeUpdatesInterval, + setShortUrlCreationSettings, + setUiSettings, + toggleRealTimeUpdates, +} from '../reducers/settings'; import { ConnectDecorator } from '../../container/types'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { ShortUrlCreation } from '../ShortUrlCreation'; +import { UserInterface } from '../UserInterface'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components - bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation'); + bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface'); bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); @@ -21,10 +27,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation); bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ])); + bottle.serviceFactory('UserInterface', () => UserInterface); + bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ])); + // Actions bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings); + bottle.serviceFactory('setUiSettings', () => setUiSettings); }; export default provideServices; diff --git a/src/short-urls/Paginator.scss b/src/short-urls/Paginator.scss index 03b784d0..b28b471a 100644 --- a/src/short-urls/Paginator.scss +++ b/src/short-urls/Paginator.scss @@ -1,7 +1,7 @@ .short-urls-paginator { position: sticky; bottom: 0; - background-color: rgba(255, 255, 255, .5); + background-color: var(--primary-color-alfa); padding: .75rem 0; - border-top: 1px solid rgba(black, .125); + border-top: 1px solid var(--border-color); } diff --git a/src/short-urls/UseExistingIfFoundInfoIcon.tsx b/src/short-urls/UseExistingIfFoundInfoIcon.tsx index c737d690..2d003a1e 100644 --- a/src/short-urls/UseExistingIfFoundInfoIcon.tsx +++ b/src/short-urls/UseExistingIfFoundInfoIcon.tsx @@ -1,8 +1,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { Modal, ModalBody, ModalHeader } from 'reactstrap'; -import './UseExistingIfFoundInfoIcon.scss'; import { useToggle } from '../utils/helpers/hooks'; +import './UseExistingIfFoundInfoIcon.scss'; const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => ( diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index e26d289a..4bde939e 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { DropdownItem, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; +import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap'; import { ExternalLink } from 'react-external-link'; import classNames from 'classnames'; import { ShortUrlModalProps } from '../data'; diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index 89255646..17ec51ac 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -5,7 +5,7 @@ @media (max-width: $responsiveTableBreakpoint) { display: block; margin-bottom: 10px; - border-bottom: 1px solid $lightGrey; + border-bottom: 1px solid var(--border-color); position: relative; } } diff --git a/src/tags/TagCard.scss b/src/tags/TagCard.scss index ce6849aa..ecb79abe 100644 --- a/src/tags/TagCard.scss +++ b/src/tags/TagCard.scss @@ -1,3 +1,5 @@ +@import '../utils/base'; + .tag-card.tag-card { margin-bottom: .5rem; } @@ -26,11 +28,11 @@ } .tag-card__tag-name { - color: #007bff; + color: $mainColor; cursor: pointer; } .tag-card__tag-name:hover { - color: #0056b3; + color: darken($mainColor, 15%); text-decoration: underline; } diff --git a/src/tags/TagCard.tsx b/src/tags/TagCard.tsx index d7359b25..b72bca0e 100644 --- a/src/tags/TagCard.tsx +++ b/src/tags/TagCard.tsx @@ -35,10 +35,10 @@ const TagCard = ( return ( - -
@@ -57,14 +57,14 @@ const TagCard = ( Short URLs {prettify(tagStats.shortUrlsCount)} Visits {prettify(tagStats.visitsCount)} diff --git a/src/theme/theme.scss b/src/theme/theme.scss new file mode 100644 index 00000000..e4f8cc64 --- /dev/null +++ b/src/theme/theme.scss @@ -0,0 +1,63 @@ +@import '../utils/base'; + +// Light theme colors +$lightPrimaryColor: #ffffff; +$lightPrimaryColorAlfa: rgba($lightPrimaryColor, .5); +$lightSecondaryColor: $lightColor; +$lightTextColor: #212529; +$lightBorderColor: rgba(0, 0, 0, .125); +$lightTableBorderColor: $mediumGrey; +$lightActiveColor: $lightGrey; +$lightBrandColor: $mainColor; +$lightInputColor: $lightPrimaryColor; +$lightInputTextColor: #495057; +$lightDisabledInputColor: $lightColor; +$lightBorderInputColor: rgba(0, 0, 0, .19); +$lightTableHighlightColor: rgba(0, 0, 0, .075); + +// Dark theme colors +$darkPrimaryColor: #161b22; +$darkPrimaryColorAlfa: rgba($darkPrimaryColor, .8); +$darkSecondaryColor: #0f131a; +$darkTextColor: rgb(201, 209, 217); +$darkBorderColor: rgba(255, 255, 255, .15); +$darkTableBorderColor: #393d43; +$darkActiveColor: $darkSecondaryColor; +$darkBrandColor: #0b2d4e; +$darkInputColor: darken($darkPrimaryColor, 2%); +$darkInputTextColor: $darkTextColor; +$darkDisabledInputColor: lighten($darkPrimaryColor, 2%); +$darkBorderInputColor: $darkBorderColor; +$darkTableHighlightColor: $darkBorderColor; + +html:not([data-theme='dark']) { + --primary-color: #{$lightPrimaryColor}; + --primary-color-alfa: #{$lightPrimaryColorAlfa}; + --secondary-color: #{$lightSecondaryColor}; + --text-color: #{$lightTextColor}; + --border-color: #{$lightBorderColor}; + --active-color: #{$lightActiveColor}; + --brand-color: #{$lightBrandColor}; + --input-color: #{$lightInputColor}; + --input-disabled-color: #{$lightDisabledInputColor}; + --input-border-color: #{$lightBorderInputColor}; + --input-text-color: #{$lightInputTextColor}; + --table-border-color: #{$lightTableBorderColor}; + --table-highlight-color: #{$lightTableHighlightColor}; +} + +html[data-theme='dark'] { + --primary-color: #{$darkPrimaryColor}; + --primary-color-alfa: #{$darkPrimaryColorAlfa}; + --secondary-color: #{$darkSecondaryColor}; + --text-color: #{$darkTextColor}; + --border-color: #{$darkBorderColor}; + --active-color: #{$darkActiveColor}; + --brand-color: #{$darkBrandColor}; + --input-color: #{$darkInputColor}; + --input-disabled-color: #{$darkDisabledInputColor}; + --input-border-color: #{$darkBorderInputColor}; + --input-text-color: #{$darkInputTextColor}; + --table-border-color: #{$darkTableBorderColor}; + --table-highlight-color: #{$darkTableHighlightColor}; +} diff --git a/src/utils/DateInput.scss b/src/utils/DateInput.scss index 9653c38b..50c4016a 100644 --- a/src/utils/DateInput.scss +++ b/src/utils/DateInput.scss @@ -10,7 +10,12 @@ } .date-input-container__input:not(:disabled) { - background-color: #ffffff !important; + background-color: var(--primary-color) !important; +} + +.card .date-input-container__input:not(:disabled), +.dropdown .date-input-container__input:not(:disabled) { + background-color: var(--input-color) !important; } .date-input-container__icon { @@ -32,3 +37,66 @@ background-color: #333333; font-size: 14px; } + +.react-datepicker__input-container, +.react-datepicker-wrapper { + display: block !important; +} + +.react-datepicker__day--keyboard-selected { + background-color: $mainColor; + + &:hover { + background-color: darken($mainColor, 12%); + } +} + +.react-datepicker.react-datepicker { + background-color: var(--primary-color); + color: var(--text-color); + border-color: var(--border-color); +} + +.react-datepicker__header.react-datepicker__header { + background-color: var(--secondary-color); + border-color: var(--border-color); +} + +.react-datepicker__current-month.react-datepicker__current-month, +.react-datepicker-time__header.react-datepicker-time__header, +.react-datepicker-year-header.react-datepicker-year-header, +.react-datepicker__day-name.react-datepicker__day-name, +.react-datepicker__day:not(:hover).react-datepicker__day:not(:hover), +.react-datepicker__time-name.react-datepicker__time-name { + color: inherit; +} + +.react-datepicker__day--keyboard-selected.react-datepicker__day--keyboard-selected, +.react-datepicker__month-text--keyboard-selected.react-datepicker__month-text--keyboard-selected, +.react-datepicker__quarter-text--keyboard-selected.react-datepicker__quarter-text--keyboard-selected, +.react-datepicker__year-text--keyboard-selected.react-datepicker__year-text--keyboard-selected { + background-color: var(--brand-color) !important; + color: white !important; +} + +.react-datepicker-popper.react-datepicker-popper { + z-index: 2; + + &[data-placement^='top'] .react-datepicker__triangle.react-datepicker__triangle { + border-top-color: var(--primary-color); + border-bottom-color: var(--border-color); + + &::before { + border-top-color: var(--border-color); + } + } + + &[data-placement^='bottom'] .react-datepicker__triangle.react-datepicker__triangle { + border-top-color: var(--border-color); + border-bottom-color: var(--secondary-color); + + &::before { + border-bottom-color: var(--border-color); + } + } +} diff --git a/src/utils/DropdownBtn.scss b/src/utils/DropdownBtn.scss index 6cfae1dc..f5c40c27 100644 --- a/src/utils/DropdownBtn.scss +++ b/src/utils/DropdownBtn.scss @@ -1,3 +1,5 @@ +/* stylelint-disable no-descending-specificity */ + @import '../utils/mixins/vertical-align'; .dropdown-btn__toggle.dropdown-btn__toggle, @@ -6,10 +8,24 @@ .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):focus, .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover, .show > .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle { - color: #6c757d; - background-color: white; text-align: left; - border-color: rgba(0, 0, 0, .125); + color: var(--input-text-color); + background-color: var(--primary-color); + border-color: var(--input-border-color); +} + +.card .dropdown-btn__toggle.dropdown-btn__toggle, +.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled).active, +.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):active, +.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):focus, +.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover, +.show > .card .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle { + background-color: var(--input-color); +} + +.dropdown-btn__toggle.dropdown-btn__toggle.disabled, +.dropdown-btn__toggle.dropdown-btn__toggle:disabled { + background-color: var(--input-disabled-color); } .dropdown-btn__toggle.dropdown-btn__toggle:after { diff --git a/src/utils/base.scss b/src/utils/base.scss index 8b70dc17..d5c52b2c 100644 --- a/src/utils/base.scss +++ b/src/utils/base.scss @@ -12,9 +12,10 @@ $responsiveTableBreakpoint: $mdMax; // Colors $mainColor: #4696e5; $lightColor: #f5f6fe; -$lightGrey: #dddddd; +$lightGrey: #eeeeee; $dangerColor: #dc3545; $mediumGrey: #dee2e6; +$textPlaceholder: #6c757d; // Misc $headerHeight: 57px; @@ -23,6 +24,6 @@ $footer-height: 2.3rem; $footer-margin: .8rem; // Bootstrap overwrites -//$theme-colors: ( -// 'primary': $mainColor -//); +$theme-colors: ( + 'primary': $mainColor +); diff --git a/src/utils/mixins/sticky-cell.scss b/src/utils/mixins/sticky-cell.scss index a20f341f..0e2d6125 100644 --- a/src/utils/mixins/sticky-cell.scss +++ b/src/utils/mixins/sticky-cell.scss @@ -12,7 +12,7 @@ left: 0; bottom: -1px; right: -1px; - background: $mediumGrey; + background: var(--table-border-color); z-index: -2; } @@ -27,7 +27,7 @@ left: 1px; bottom: 0; right: 0; - background: white; + background: var(--primary-color); z-index: -1; } diff --git a/src/utils/theme/index.ts b/src/utils/theme/index.ts index a4b5f96b..3f47f7eb 100644 --- a/src/utils/theme/index.ts +++ b/src/utils/theme/index.ts @@ -2,6 +2,24 @@ export const MAIN_COLOR = '#4696e5'; export const MAIN_COLOR_ALPHA = 'rgba(70, 150, 229, 0.4)'; -export const HIGHLIGHTED_COLOR = '#F77F28'; +export const HIGHLIGHTED_COLOR = '#f77f28'; export const HIGHLIGHTED_COLOR_ALPHA = 'rgba(247, 127, 40, 0.4)'; + +export const PRIMARY_LIGHT_COLOR = 'white'; + +export const PRIMARY_DARK_COLOR = '#161b22'; + +export type Theme = 'dark' | 'light'; + +export const changeThemeInMarkup = (theme: Theme) => { + const html = document.getElementsByTagName('html'); + + html?.[0]?.setAttribute('data-theme', theme); +}; + +export const isDarkThemeEnabled = (): boolean => { + const html = document.getElementsByTagName('html'); + + return html?.[0]?.getAttribute('data-theme') === 'dark'; +}; diff --git a/src/visits/VisitsStats.scss b/src/visits/VisitsStats.scss index dc945d12..1caafc6d 100644 --- a/src/visits/VisitsStats.scss +++ b/src/visits/VisitsStats.scss @@ -25,6 +25,6 @@ .visits-stats__nav-link.active { border-color: $mainColor; - background-color: white !important; + background-color: var(--primary-color) !important; color: $mainColor !important; } diff --git a/src/visits/VisitsTable.scss b/src/visits/VisitsTable.scss index a8ad3f24..2b20f59c 100644 --- a/src/visits/VisitsTable.scss +++ b/src/visits/VisitsTable.scss @@ -4,7 +4,7 @@ .visits-table { margin: 1.5rem 0 0; position: relative; - background-color: white; + background-color: var(--primary-color); overflow-y: hidden; } diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 54e16602..fb99a950 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -154,7 +154,7 @@ const VisitsTable = ({ setSelectedVisits( isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ], )} diff --git a/src/visits/helpers/DefaultChart.tsx b/src/visits/helpers/DefaultChart.tsx index 78d1863a..74dd083a 100644 --- a/src/visits/helpers/DefaultChart.tsx +++ b/src/visits/helpers/DefaultChart.tsx @@ -7,7 +7,15 @@ import { fillTheGaps } from '../../utils/helpers/visits'; import { Stats } from '../types'; import { prettify } from '../../utils/helpers/numbers'; import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts'; -import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../utils/theme'; +import { + HIGHLIGHTED_COLOR, + HIGHLIGHTED_COLOR_ALPHA, + isDarkThemeEnabled, + MAIN_COLOR, + MAIN_COLOR_ALPHA, + PRIMARY_DARK_COLOR, + PRIMARY_LIGHT_COLOR, +} from '../../utils/theme'; import './DefaultChart.scss'; export interface DefaultChartProps { @@ -47,7 +55,7 @@ const generateGraphData = ( '#DCDCDC', '#463730', ], - borderColor: isBarChart ? MAIN_COLOR : 'white', + borderColor: isBarChart ? MAIN_COLOR : (isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR), borderWidth: 2, }, highlightedData && { diff --git a/test/App.test.tsx b/test/App.test.tsx index 26938298..da1fda0b 100644 --- a/test/App.test.tsx +++ b/test/App.test.tsx @@ -1,6 +1,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Route } from 'react-router-dom'; import { identity } from 'ramda'; +import { Mock } from 'ts-mockery'; +import { Settings } from '../src/settings/reducers/settings'; import appFactory from '../src/App'; describe('', () => { @@ -10,7 +12,7 @@ describe('', () => { beforeEach(() => { const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, () => null); - wrapper = shallow(); + wrapper = shallow(()} />); }); afterEach(() => wrapper.unmount()); diff --git a/test/settings/ShortUrlCreation.test.tsx b/test/settings/ShortUrlCreation.test.tsx index 07d0bd7b..1cb92d9a 100644 --- a/test/settings/ShortUrlCreation.test.tsx +++ b/test/settings/ShortUrlCreation.test.tsx @@ -25,7 +25,7 @@ describe('', () => { [{ validateUrls: true }, true ], [{ validateUrls: false }, false ], [ undefined, false ], - ])('switch is toggled if option is tru', (shortUrlCreation, expectedChecked) => { + ])('switch is toggled if option is true', (shortUrlCreation, expectedChecked) => { const wrapper = createWrapper(shortUrlCreation); const toggle = wrapper.find(ToggleSwitch); diff --git a/test/settings/UserInterface.test.tsx b/test/settings/UserInterface.test.tsx new file mode 100644 index 00000000..02a0a037 --- /dev/null +++ b/test/settings/UserInterface.test.tsx @@ -0,0 +1,60 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Settings, UiSettings } from '../../src/settings/reducers/settings'; +import { UserInterface } from '../../src/settings/UserInterface'; +import ToggleSwitch from '../../src/utils/ToggleSwitch'; +import { Theme } from '../../src/utils/theme'; + +describe('', () => { + let wrapper: ShallowWrapper; + const setUiSettings = jest.fn(); + const createWrapper = (ui?: UiSettings) => { + wrapper = shallow( + ({ ui })} + setUiSettings={setUiSettings} + />, + ); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); + + it.each([ + [{ theme: 'dark' as Theme }, true ], + [{ theme: 'light' as Theme }, false ], + [ undefined, false ], + ])('toggles switch if theme is dark', (ui, expectedChecked) => { + const wrapper = createWrapper(ui); + const toggle = wrapper.find(ToggleSwitch); + + expect(toggle.prop('checked')).toEqual(expectedChecked); + }); + + it.each([ + [{ theme: 'dark' as Theme }, faMoon ], + [{ theme: 'light' as Theme }, faSun ], + [ undefined, faSun ], + ])('shows different icons based on theme', (ui, expectedIcon) => { + const wrapper = createWrapper(ui); + const icon = wrapper.find(FontAwesomeIcon); + + expect(icon.prop('icon')).toEqual(expectedIcon); + }); + + it.each([ + [ true, 'dark' ], + [ false, 'light' ], + ])('invokes setUiSettings when toggle value changes', (checked, theme) => { + const wrapper = createWrapper(); + const toggle = wrapper.find(ToggleSwitch); + + expect(setUiSettings).not.toHaveBeenCalled(); + toggle.simulate('change', checked); + expect(setUiSettings).toHaveBeenCalledWith({ theme }); + }); +}); diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index 1bfd1701..57d22066 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -3,12 +3,14 @@ import reducer, { toggleRealTimeUpdates, setRealTimeUpdatesInterval, setShortUrlCreationSettings, + setUiSettings, } from '../../../src/settings/reducers/settings'; describe('settingsReducer', () => { const realTimeUpdates = { enabled: true }; const shortUrlCreation = { validateUrls: false }; - const settings = { realTimeUpdates, shortUrlCreation }; + const ui = { theme: 'light' }; + const settings = { realTimeUpdates, shortUrlCreation, ui }; describe('reducer', () => { it('returns realTimeUpdates when action is SET_SETTINGS', () => { @@ -39,4 +41,12 @@ describe('settingsReducer', () => { expect(result).toEqual({ type: SET_SETTINGS, shortUrlCreation: { validateUrls: true } }); }); }); + + describe('setUiSettings', () => { + it('creates action to set ui settings', () => { + const result = setUiSettings({ theme: 'dark' }); + + expect(result).toEqual({ type: SET_SETTINGS, ui: { theme: 'dark' } }); + }); + }); }); diff --git a/test/short-urls/helpers/EditTagsModal.test.tsx b/test/short-urls/helpers/EditTagsModal.test.tsx index 3da11f62..fddccad0 100644 --- a/test/short-urls/helpers/EditTagsModal.test.tsx +++ b/test/short-urls/helpers/EditTagsModal.test.tsx @@ -1,10 +1,10 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import { Modal } from 'reactstrap'; import { Mock } from 'ts-mockery'; import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal'; import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrlTags } from '../../../src/short-urls/reducers/shortUrlTags'; import { OptionalString } from '../../../src/utils/utils'; +import { Modal } from 'reactstrap'; describe('', () => { let wrapper: ShallowWrapper; diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx index 339233d0..a2d01c4a 100644 --- a/test/visits/VisitsTable.test.tsx +++ b/test/visits/VisitsTable.test.tsx @@ -85,7 +85,7 @@ describe('', () => { ); expect(wrapper.find('.text-primary')).toHaveLength(3); - expect(wrapper.find('.table-primary')).toHaveLength(2); + expect(wrapper.find('.table-active')).toHaveLength(2); // Select one extra wrapper.find('tr').at(5).simulate('click'); diff --git a/test/visits/helpers/MapModal.test.tsx b/test/visits/helpers/MapModal.test.tsx index 9c286b11..71cdda26 100644 --- a/test/visits/helpers/MapModal.test.tsx +++ b/test/visits/helpers/MapModal.test.tsx @@ -1,8 +1,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import { Modal } from 'reactstrap'; import { Marker, Popup } from 'react-leaflet'; import MapModal from '../../../src/visits/helpers/MapModal'; import { CityStats } from '../../../src/visits/types'; +import { Modal } from 'reactstrap'; describe('', () => { let wrapper: ShallowWrapper;