Merge pull request #391 from acelaya-forks/feature/dark-theme

Feature/dark theme
This commit is contained in:
Alejandro Celaya 2021-02-27 09:02:58 +01:00 committed by GitHub
commit f653739d50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 501 additions and 97 deletions

View file

@ -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. * [#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. * [#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. * [#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 ### Changed
* *Nothing* * *Nothing*

View file

@ -2,11 +2,14 @@ import { useEffect, FC } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import NotFound from './common/NotFound'; import NotFound from './common/NotFound';
import { ServersMap } from './servers/data'; import { ServersMap } from './servers/data';
import { Settings } from './settings/reducers/settings';
import { changeThemeInMarkup } from './utils/theme';
import './App.scss'; import './App.scss';
interface AppProps { interface AppProps {
fetchServers: Function; fetchServers: Function;
servers: ServersMap; servers: ServersMap;
settings: Settings;
} }
const App = ( const App = (
@ -17,12 +20,14 @@ const App = (
EditServer: FC, EditServer: FC,
Settings: FC, Settings: FC,
ShlinkVersionsContainer: FC, ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers }: AppProps) => { ) => ({ fetchServers, servers, settings }: AppProps) => {
// On first load, try to fetch the remote servers if the list is empty
useEffect(() => { useEffect(() => {
// On first load, try to fetch the remote servers if the list is empty
if (Object.keys(servers).length === 0) { if (Object.keys(servers).length === 0) {
fetchServers(); fetchServers();
} }
changeThemeInMarkup(settings.ui?.theme ?? 'light');
}, []); }, []);
return ( return (

View file

@ -3,7 +3,7 @@
.aside-menu { .aside-menu {
width: $asideMenuWidth; width: $asideMenuWidth;
background-color: white; background-color: var(--primary-color);
box-shadow: rgba(0, 0, 0, .05) 0 8px 15px; box-shadow: rgba(0, 0, 0, .05) 0 8px 15px;
position: fixed !important; position: fixed !important;
padding-top: 13px; padding-top: 13px;
@ -18,7 +18,6 @@
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
padding: 30px 15px 15px; padding: 30px 15px 15px;
border-right: 1px solid #eeeeee;
} }
@media (max-width: $smMax) { @media (max-width: $smMax) {
@ -50,17 +49,13 @@
} }
.aside-menu__item:hover { .aside-menu__item:hover {
background-color: $lightColor; background-color: var(--secondary-color);
}
.aside-menu__item--selected {
color: #ffffff;
background-color: $mainColor;
} }
.aside-menu__item--selected,
.aside-menu__item--selected:hover { .aside-menu__item--selected:hover {
color: #ffffff; color: #ffffff;
background-color: $mainColor; background-color: var(--brand-color);
} }
.aside-menu__item--divider { .aside-menu__item--divider {

View file

@ -36,6 +36,6 @@
.home__servers-container { .home__servers-container {
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
border-left: 1px solid rgba(0, 0, 0, .125); border-left: 1px solid var(--border-color);
} }
} }

View file

@ -1,8 +1,8 @@
@import '../utils/base'; @import '../utils/base';
.main-header.main-header { .main-header.main-header {
background-color: $mainColor !important;
color: white; color: white;
background-color: var(--brand-color) !important;
.navbar-brand { .navbar-brand {
color: inherit !important; color: inherit !important;

View file

@ -1,6 +1,8 @@
@import '../utils/base';
.react-tagsinput { .react-tagsinput {
background-color: #ffffff; background-color: var(--input-color);
border: 1px solid #cccccc; border: 1px solid var(--input-border-color);
border-radius: .25rem; border-radius: .25rem;
overflow: hidden; overflow: hidden;
min-height: 2.6rem; min-height: 2.6rem;
@ -10,7 +12,7 @@
.react-tagsinput--focused { .react-tagsinput--focused {
border-color: #80bdff; 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 { .react-tagsinput-tag {
@ -44,5 +46,13 @@
width: 100%; width: 100%;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 1.25rem; 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);
} }

View file

@ -43,7 +43,7 @@ bottle.serviceFactory(
'Settings', 'Settings',
'ShlinkVersionsContainer', 'ShlinkVersionsContainer',
); );
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ])); bottle.decorator('App', connect([ 'servers', 'settings' ], [ 'fetchServers' ]));
provideCommonServices(bottle, connect, withRouter); provideCommonServices(bottle, connect, withRouter);
provideApiServices(bottle); provideApiServices(bottle);

View file

@ -1,12 +1,19 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align'; @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,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover, .domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active { .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,
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover { .domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
border-color: #ced4da; border-color: var(--border-color);
} }

View file

@ -54,7 +54,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
) : ( ) : (
<DropdownBtn <DropdownBtn
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`} text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : ''} className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : 'domains-dropdown__toggle-btn'}
> >
{domains.map(({ domain, isDefault }) => ( {domains.map(({ domain, isDefault }) => (
<DropdownItem <DropdownItem

View file

@ -1,32 +1,87 @@
/* stylelint-disable no-descending-specificity */
@import './utils/base'; @import './utils/base';
@import 'node_modules/bootstrap/scss/bootstrap.scss'; @import 'node_modules/bootstrap/scss/bootstrap.scss';
@import './common/react-tagsinput.scss'; @import './common/react-tagsinput.scss';
@import './theme/theme';
* {
outline: none !important;
}
html, html,
body, body,
#root { #root {
height: 100%; height: 100%;
background: $lightColor; background: var(--secondary-color);
} color: var(--text-color);
* {
outline: none !important;
} }
.bg-main { .bg-main {
background-color: $mainColor !important; background-color: $mainColor !important;
} }
.card { .card-body,
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075); .card-header,
} .list-group-item {
background-color: transparent;
.card-header {
background-color: white;
} }
.card-footer { .card-footer {
background-color: rgba(255, 255, 255, .5); background-color: var(--primary-color-alfa);
}
.card {
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
background-color: var(--primary-color);
border-color: var(--border-color);
}
.list-group {
background-color: var(--primary-color);
}
.modal-content,
.page-link,
.page-item.disabled .page-link,
.dropdown-menu {
background-color: var(--primary-color);
}
.modal-header,
.modal-footer,
.card-header,
.card-footer,
.table thead th,
.table th,
.table td,
.page-link,
.page-link:hover,
.page-item.disabled .page-link,
.dropdown-divider,
.dropdown-menu,
.list-group-item,
.modal-content {
border-color: var(--border-color);
}
.table-bordered,
.table-bordered thead th,
.table-bordered thead td {
border-color: var(--table-border-color);
}
.page-link:hover {
background-color: var(--secondary-color);
}
.page-item.active .page-link {
background-color: var(--brand-color);
border-color: var(--brand-color);
}
.pagination .page-link {
cursor: pointer;
} }
.container-xl { .container-xl {
@ -40,32 +95,61 @@ body,
} }
} }
.dropdown-item,
.dropdown-item-text {
color: var(--text-color);
}
.dropdown-item:not(:disabled) { .dropdown-item:not(:disabled) {
cursor: pointer; cursor: pointer;
} }
.dropdown-item:focus:not(:disabled),
.dropdown-item:hover:not(:disabled),
.dropdown-item.active:not(:disabled), .dropdown-item.active:not(:disabled),
.dropdown-item:active:not(:disabled) { .dropdown-item:active:not(:disabled) {
background-color: $lightGrey !important; background-color: var(--active-color) !important;
color: inherit !important; color: var(--text-color) !important;
} }
.badge-main { .badge-main {
color: #ffffff; color: #ffffff;
background-color: $mainColor; background-color: var(--brand-color);
}
.close,
.close:hover,
.table,
.table-hover tbody tr:hover {
color: var(--text-color);
} }
.table-hover tbody tr:hover { .table-hover tbody tr:hover {
background-color: $lightColor; background-color: var(--secondary-color);
} }
.react-datepicker__input-container, .form-control,
.react-datepicker-wrapper { .form-control:focus {
display: block !important; background-color: var(--primary-color);
border-color: var(--input-border-color);
color: var(--input-text-color);
} }
.react-datepicker-popper { .form-control.disabled,
z-index: 2; .form-control:disabled {
background-color: var(--input-disabled-color);
cursor: not-allowed;
}
.card .form-control:not(:disabled),
.card .form-control:not(:disabled):hover {
background-color: var(--input-color);
}
.table-active,
.table-active > th,
.table-active > td {
background-color: var(--table-highlight-color) !important;
} }
.navbar-brand { .navbar-brand {
@ -74,10 +158,6 @@ body,
} }
} }
.pagination .page-link {
cursor: pointer;
}
.indivisible { .indivisible {
white-space: nowrap; white-space: nowrap;
} }
@ -92,14 +172,6 @@ body,
white-space: nowrap; white-space: nowrap;
} }
.react-datepicker__day--keyboard-selected {
background-color: $mainColor;
&:hover {
background-color: darken($mainColor, 12%);
}
}
.progress-bar { .progress-bar {
background-color: $mainColor; background-color: $mainColor;
} }

View file

@ -2,10 +2,10 @@
.overview__card.overview__card { .overview__card.overview__card {
text-align: center; text-align: center;
border-top: 3px solid $mainColor; border-top: 3px solid var(--brand-color);
} }
.overview__card-title { .overview__card-title {
text-transform: uppercase; text-transform: uppercase;
color: #6c757d; color: $textPlaceholder;
} }

View file

@ -18,7 +18,7 @@
} }
.servers-list__server-item:hover { .servers-list__server-item:hover {
background-color: $lightColor; background-color: var(--secondary-color);
} }
.servers-list__server-item-icon { .servers-list__server-item-icon {
@ -29,7 +29,7 @@
.servers-list__list-group--embedded.servers-list__list-group--embedded { .servers-list__list-group--embedded.servers-list__list-group--embedded {
border-radius: 0; border-radius: 0;
border-top: 1px solid rgba(0, 0, 0, .125); border-top: 1px solid var(--border-color);
@media (min-width: $mdMin) { @media (min-width: $mdMin) {
max-height: 220px; max-height: 220px;
@ -40,6 +40,6 @@
.servers-list__server-item { .servers-list__server-item {
border: none; border: none;
border-bottom: 1px solid rgba(0, 0, 0, .125); border-bottom: 1px solid var(--border-color);
} }
} }

View file

@ -19,6 +19,9 @@ const RealTimeUpdates = (
<FormGroup> <FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}> <ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer. Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
<small className="form-text text-muted">
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</small>
</ToggleSwitch> </ToggleSwitch>
</FormGroup> </FormGroup>
<FormGroup className="mb-0"> <FormGroup className="mb-0">

View file

@ -2,14 +2,19 @@ import { FC } from 'react';
import { Row } from 'reactstrap'; import { Row } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout'; import NoMenuLayout from '../common/NoMenuLayout';
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC) => () => ( const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC) => () => (
<NoMenuLayout> <NoMenuLayout>
<Row> <Row>
<div className="col-lg-6"> <div className="col-lg-6">
<RealTimeUpdates /> <div className="mb-3 mb-md-4">
<UserInterface />
</div>
<div className="mb-3 mb-md-4">
<ShortUrlCreation />
</div>
</div> </div>
<div className="col-lg-6"> <div className="col-lg-6">
<ShortUrlCreation /> <RealTimeUpdates />
</div> </div>
</Row> </Row>
</NoMenuLayout> </NoMenuLayout>

View file

@ -0,0 +1,4 @@
.user-interface__theme-icon {
float: right;
margin-top: .25rem;
}

View file

@ -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<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
<SimpleCard title="User interface">
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={ui?.theme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ theme });
changeThemeInMarkup(theme);
}}
>
Use dark theme.
</ToggleSwitch>
</SimpleCard>
);

View file

@ -2,6 +2,7 @@ import { Action } from 'redux';
import { dissoc, mergeDeepRight } from 'ramda'; import { dissoc, mergeDeepRight } from 'ramda';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { RecursivePartial } from '../../utils/utils'; import { RecursivePartial } from '../../utils/utils';
import { Theme } from '../../utils/theme';
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
@ -19,9 +20,14 @@ export interface ShortUrlCreationSettings {
validateUrls: boolean; validateUrls: boolean;
} }
export interface UiSettings {
theme: Theme;
}
export interface Settings { export interface Settings {
realTimeUpdates: RealTimeUpdatesSettings; realTimeUpdates: RealTimeUpdatesSettings;
shortUrlCreation?: ShortUrlCreationSettings; shortUrlCreation?: ShortUrlCreationSettings;
ui?: UiSettings;
} }
const initialState: Settings = { const initialState: Settings = {
@ -31,6 +37,9 @@ const initialState: Settings = {
shortUrlCreation: { shortUrlCreation: {
validateUrls: false, validateUrls: false,
}, },
ui: {
theme: 'light',
},
}; };
type SettingsAction = Action & Settings; type SettingsAction = Action & Settings;
@ -55,3 +64,8 @@ export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings):
type: SET_SETTINGS, type: SET_SETTINGS,
shortUrlCreation: settings, shortUrlCreation: settings,
}); });
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
ui: settings,
});

View file

@ -1,14 +1,20 @@
import Bottle from 'bottlejs'; import Bottle from 'bottlejs';
import RealTimeUpdates from '../RealTimeUpdates'; import RealTimeUpdates from '../RealTimeUpdates';
import Settings from '../Settings'; 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 { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ShortUrlCreation } from '../ShortUrlCreation'; import { ShortUrlCreation } from '../ShortUrlCreation';
import { UserInterface } from '../UserInterface';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation'); bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface');
bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
@ -21,10 +27,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation); bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation);
bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ])); bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
bottle.serviceFactory('UserInterface', () => UserInterface);
bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ]));
// Actions // Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings); bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
bottle.serviceFactory('setUiSettings', () => setUiSettings);
}; };
export default provideServices; export default provideServices;

View file

@ -1,7 +1,7 @@
.short-urls-paginator { .short-urls-paginator {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
background-color: rgba(255, 255, 255, .5); background-color: var(--primary-color-alfa);
padding: .75rem 0; padding: .75rem 0;
border-top: 1px solid rgba(black, .125); border-top: 1px solid var(--border-color);
} }

View file

@ -1,8 +1,8 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { Modal, ModalBody, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './UseExistingIfFoundInfoIcon.scss';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import './UseExistingIfFoundInfoIcon.scss';
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => ( const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg"> <Modal isOpen={isOpen} toggle={toggle} centered size="lg">

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; 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 { ExternalLink } from 'react-external-link';
import classNames from 'classnames'; import classNames from 'classnames';
import { ShortUrlModalProps } from '../data'; import { ShortUrlModalProps } from '../data';

View file

@ -5,7 +5,7 @@
@media (max-width: $responsiveTableBreakpoint) { @media (max-width: $responsiveTableBreakpoint) {
display: block; display: block;
margin-bottom: 10px; margin-bottom: 10px;
border-bottom: 1px solid $lightGrey; border-bottom: 1px solid var(--border-color);
position: relative; position: relative;
} }
} }

View file

@ -1,3 +1,5 @@
@import '../utils/base';
.tag-card.tag-card { .tag-card.tag-card {
margin-bottom: .5rem; margin-bottom: .5rem;
} }
@ -26,11 +28,11 @@
} }
.tag-card__tag-name { .tag-card__tag-name {
color: #007bff; color: $mainColor;
cursor: pointer; cursor: pointer;
} }
.tag-card__tag-name:hover { .tag-card__tag-name:hover {
color: #0056b3; color: darken($mainColor, 15%);
text-decoration: underline; text-decoration: underline;
} }

View file

@ -35,10 +35,10 @@ const TagCard = (
return ( return (
<Card className="tag-card"> <Card className="tag-card">
<CardHeader className="tag-card__header"> <CardHeader className="tag-card__header">
<Button color="light" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}> <Button color="link" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} /> <FontAwesomeIcon icon={deleteIcon} />
</Button> </Button>
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}> <Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} /> <FontAwesomeIcon icon={editIcon} />
</Button> </Button>
<h5 className="tag-card__tag-title text-ellipsis"> <h5 className="tag-card__tag-title text-ellipsis">
@ -57,14 +57,14 @@ const TagCard = (
<CardBody className="tag-card__body"> <CardBody className="tag-card__body">
<Link <Link
to={shortUrlsLink} to={shortUrlsLink}
className="btn btn-light btn-block d-flex justify-content-between align-items-center mb-1" className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
> >
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span> <span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
<b>{prettify(tagStats.shortUrlsCount)}</b> <b>{prettify(tagStats.shortUrlsCount)}</b>
</Link> </Link>
<Link <Link
to={`/server/${serverId}/tag/${tag}/visits`} to={`/server/${serverId}/tag/${tag}/visits`}
className="btn btn-light btn-block d-flex justify-content-between align-items-center" className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
> >
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span> <span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
<b>{prettify(tagStats.visitsCount)}</b> <b>{prettify(tagStats.visitsCount)}</b>

63
src/theme/theme.scss Normal file
View file

@ -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};
}

View file

@ -10,7 +10,12 @@
} }
.date-input-container__input:not(:disabled) { .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 { .date-input-container__icon {
@ -32,3 +37,66 @@
background-color: #333333; background-color: #333333;
font-size: 14px; 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);
}
}
}

View file

@ -1,3 +1,5 @@
/* stylelint-disable no-descending-specificity */
@import '../utils/mixins/vertical-align'; @import '../utils/mixins/vertical-align';
.dropdown-btn__toggle.dropdown-btn__toggle, .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):focus,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover, .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover,
.show > .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle { .show > .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle {
color: #6c757d;
background-color: white;
text-align: left; 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 { .dropdown-btn__toggle.dropdown-btn__toggle:after {

View file

@ -12,9 +12,10 @@ $responsiveTableBreakpoint: $mdMax;
// Colors // Colors
$mainColor: #4696e5; $mainColor: #4696e5;
$lightColor: #f5f6fe; $lightColor: #f5f6fe;
$lightGrey: #dddddd; $lightGrey: #eeeeee;
$dangerColor: #dc3545; $dangerColor: #dc3545;
$mediumGrey: #dee2e6; $mediumGrey: #dee2e6;
$textPlaceholder: #6c757d;
// Misc // Misc
$headerHeight: 57px; $headerHeight: 57px;
@ -23,6 +24,6 @@ $footer-height: 2.3rem;
$footer-margin: .8rem; $footer-margin: .8rem;
// Bootstrap overwrites // Bootstrap overwrites
//$theme-colors: ( $theme-colors: (
// 'primary': $mainColor 'primary': $mainColor
//); );

View file

@ -12,7 +12,7 @@
left: 0; left: 0;
bottom: -1px; bottom: -1px;
right: -1px; right: -1px;
background: $mediumGrey; background: var(--table-border-color);
z-index: -2; z-index: -2;
} }
@ -27,7 +27,7 @@
left: 1px; left: 1px;
bottom: 0; bottom: 0;
right: 0; right: 0;
background: white; background: var(--primary-color);
z-index: -1; z-index: -1;
} }

View file

@ -2,6 +2,24 @@ export const MAIN_COLOR = '#4696e5';
export const MAIN_COLOR_ALPHA = 'rgba(70, 150, 229, 0.4)'; 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 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';
};

View file

@ -25,6 +25,6 @@
.visits-stats__nav-link.active { .visits-stats__nav-link.active {
border-color: $mainColor; border-color: $mainColor;
background-color: white !important; background-color: var(--primary-color) !important;
color: $mainColor !important; color: $mainColor !important;
} }

View file

@ -4,7 +4,7 @@
.visits-table { .visits-table {
margin: 1.5rem 0 0; margin: 1.5rem 0 0;
position: relative; position: relative;
background-color: white; background-color: var(--primary-color);
overflow-y: hidden; overflow-y: hidden;
} }

View file

@ -154,7 +154,7 @@ const VisitsTable = ({
<tr <tr
key={index} key={index}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
className={classNames({ 'table-primary': isSelected })} className={classNames({ 'table-active': isSelected })}
onClick={() => setSelectedVisits( onClick={() => setSelectedVisits(
isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ], isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ],
)} )}

View file

@ -7,7 +7,15 @@ import { fillTheGaps } from '../../utils/helpers/visits';
import { Stats } from '../types'; import { Stats } from '../types';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts'; 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'; import './DefaultChart.scss';
export interface DefaultChartProps { export interface DefaultChartProps {
@ -47,7 +55,7 @@ const generateGraphData = (
'#DCDCDC', '#DCDCDC',
'#463730', '#463730',
], ],
borderColor: isBarChart ? MAIN_COLOR : 'white', borderColor: isBarChart ? MAIN_COLOR : (isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR),
borderWidth: 2, borderWidth: 2,
}, },
highlightedData && { highlightedData && {

View file

@ -1,6 +1,8 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { identity } from 'ramda'; import { identity } from 'ramda';
import { Mock } from 'ts-mockery';
import { Settings } from '../src/settings/reducers/settings';
import appFactory from '../src/App'; import appFactory from '../src/App';
describe('<App />', () => { describe('<App />', () => {
@ -10,7 +12,7 @@ describe('<App />', () => {
beforeEach(() => { beforeEach(() => {
const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, () => null); const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, () => null);
wrapper = shallow(<App fetchServers={identity} servers={{}} />); wrapper = shallow(<App fetchServers={identity} servers={{}} settings={Mock.all<Settings>()} />);
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -25,7 +25,7 @@ describe('<ShortUrlCreation />', () => {
[{ validateUrls: true }, true ], [{ validateUrls: true }, true ],
[{ validateUrls: false }, false ], [{ validateUrls: false }, false ],
[ undefined, 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 wrapper = createWrapper(shortUrlCreation);
const toggle = wrapper.find(ToggleSwitch); const toggle = wrapper.find(ToggleSwitch);

View file

@ -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('<UserInterface />', () => {
let wrapper: ShallowWrapper;
const setUiSettings = jest.fn();
const createWrapper = (ui?: UiSettings) => {
wrapper = shallow(
<UserInterface
settings={Mock.of<Settings>({ 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 });
});
});

View file

@ -3,12 +3,14 @@ import reducer, {
toggleRealTimeUpdates, toggleRealTimeUpdates,
setRealTimeUpdatesInterval, setRealTimeUpdatesInterval,
setShortUrlCreationSettings, setShortUrlCreationSettings,
setUiSettings,
} from '../../../src/settings/reducers/settings'; } from '../../../src/settings/reducers/settings';
describe('settingsReducer', () => { describe('settingsReducer', () => {
const realTimeUpdates = { enabled: true }; const realTimeUpdates = { enabled: true };
const shortUrlCreation = { validateUrls: false }; const shortUrlCreation = { validateUrls: false };
const settings = { realTimeUpdates, shortUrlCreation }; const ui = { theme: 'light' };
const settings = { realTimeUpdates, shortUrlCreation, ui };
describe('reducer', () => { describe('reducer', () => {
it('returns realTimeUpdates when action is SET_SETTINGS', () => { it('returns realTimeUpdates when action is SET_SETTINGS', () => {
@ -39,4 +41,12 @@ describe('settingsReducer', () => {
expect(result).toEqual({ type: SET_SETTINGS, shortUrlCreation: { validateUrls: true } }); 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' } });
});
});
}); });

View file

@ -1,10 +1,10 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Modal } from 'reactstrap';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal'; import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
import { ShortUrlTags } from '../../../src/short-urls/reducers/shortUrlTags'; import { ShortUrlTags } from '../../../src/short-urls/reducers/shortUrlTags';
import { OptionalString } from '../../../src/utils/utils'; import { OptionalString } from '../../../src/utils/utils';
import { Modal } from 'reactstrap';
describe('<EditTagsModal />', () => { describe('<EditTagsModal />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;

View file

@ -85,7 +85,7 @@ describe('<VisitsTable />', () => {
); );
expect(wrapper.find('.text-primary')).toHaveLength(3); expect(wrapper.find('.text-primary')).toHaveLength(3);
expect(wrapper.find('.table-primary')).toHaveLength(2); expect(wrapper.find('.table-active')).toHaveLength(2);
// Select one extra // Select one extra
wrapper.find('tr').at(5).simulate('click'); wrapper.find('tr').at(5).simulate('click');

View file

@ -1,8 +1,8 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Modal } from 'reactstrap';
import { Marker, Popup } from 'react-leaflet'; import { Marker, Popup } from 'react-leaflet';
import MapModal from '../../../src/visits/helpers/MapModal'; import MapModal from '../../../src/visits/helpers/MapModal';
import { CityStats } from '../../../src/visits/types'; import { CityStats } from '../../../src/visits/types';
import { Modal } from 'reactstrap';
describe('<MapModal />', () => { describe('<MapModal />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;