diff --git a/app/soapbox/actions/admin.js b/app/soapbox/actions/admin.js index cf3a5d0e7..4329aa670 100644 --- a/app/soapbox/actions/admin.js +++ b/app/soapbox/actions/admin.js @@ -8,6 +8,18 @@ export const ADMIN_REPORTS_FETCH_REQUEST = 'ADMIN_REPORTS_FETCH_REQUEST'; export const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS'; export const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL'; +export const ADMIN_USERS_FETCH_REQUEST = 'ADMIN_USERS_FETCH_REQUEST'; +export const ADMIN_USERS_FETCH_SUCCESS = 'ADMIN_USERS_FETCH_SUCCESS'; +export const ADMIN_USERS_FETCH_FAIL = 'ADMIN_USERS_FETCH_FAIL'; + +export const ADMIN_USERS_DELETE_REQUEST = 'ADMIN_USERS_DELETE_REQUEST'; +export const ADMIN_USERS_DELETE_SUCCESS = 'ADMIN_USERS_DELETE_SUCCESS'; +export const ADMIN_USERS_DELETE_FAIL = 'ADMIN_USERS_DELETE_FAIL'; + +export const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST'; +export const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS'; +export const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL'; + export function updateAdminConfig(params) { return (dispatch, getState) => { dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST }); @@ -33,3 +45,42 @@ export function fetchReports(params) { }); }; } + +export function fetchUsers(params) { + return (dispatch, getState) => { + dispatch({ type: ADMIN_USERS_FETCH_REQUEST, params }); + return api(getState) + .get('/api/pleroma/admin/users', { params }) + .then(({ data }) => { + dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, data, params }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, params }); + }); + }; +} + +export function deleteUsers(nicknames) { + return (dispatch, getState) => { + dispatch({ type: ADMIN_USERS_DELETE_REQUEST, nicknames }); + return api(getState) + .delete('/api/pleroma/admin/users', { data: { nicknames } }) + .then(({ data: nicknames }) => { + dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, nicknames }); + }); + }; +} + +export function approveUsers(nicknames) { + return (dispatch, getState) => { + dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, nicknames }); + return api(getState) + .patch('/api/pleroma/admin/users/approve', { nicknames }) + .then(({ data: { users } }) => { + dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, nicknames }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, nicknames }); + }); + }; +} diff --git a/app/soapbox/components/icon_with_badge.js b/app/soapbox/components/icon_with_badge.js deleted file mode 100644 index 741401982..000000000 --- a/app/soapbox/components/icon_with_badge.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { shortNumberFormat } from 'soapbox/utils/numbers'; - -const IconWithBadge = ({ id, count, className }) => { - if (count < 1) return null; - - return ( - - {count > 0 && {shortNumberFormat(count)}} - - ); -}; - -IconWithBadge.propTypes = { - id: PropTypes.string.isRequired, - count: PropTypes.number.isRequired, - className: PropTypes.string, -}; - -export default IconWithBadge; diff --git a/app/soapbox/components/icon_with_counter.js b/app/soapbox/components/icon_with_counter.js new file mode 100644 index 000000000..0daf608da --- /dev/null +++ b/app/soapbox/components/icon_with_counter.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'soapbox/components/icon'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +const IconWithCounter = ({ icon, count, fixedWidth }) => { + return ( +
+ + {count > 0 && + {shortNumberFormat(count)} + } +
+ ); +}; + +IconWithCounter.propTypes = { + icon: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + fixedWidth: PropTypes.bool, +}; + +export default IconWithCounter; diff --git a/app/soapbox/features/admin/awaiting_approval.js b/app/soapbox/features/admin/awaiting_approval.js new file mode 100644 index 000000000..4486f4b27 --- /dev/null +++ b/app/soapbox/features/admin/awaiting_approval.js @@ -0,0 +1,83 @@ +import React from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import IconButton from 'soapbox/components/icon_button'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { fetchUsers, deleteUsers, approveUsers } from 'soapbox/actions/admin'; + +const messages = defineMessages({ + heading: { id: 'column.admin.awaiting_approval', defaultMessage: 'Awaiting Approval' }, + emptyMessage: { id: 'admin.awaiting_approval.empty_message', defaultMessage: 'There is nobody waiting for approval. When a new user signs up, you can review them here.' }, +}); + +const mapStateToProps = state => { + const nicknames = state.getIn(['admin', 'awaitingApproval']); + return { + users: nicknames.toList().map(nickname => state.getIn(['admin', 'users', nickname])), + }; +}; + +export default @connect(mapStateToProps) +@injectIntl +class AwaitingApproval extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + users: ImmutablePropTypes.list.isRequired, + }; + + state = { + isLoading: true, + } + + componentDidMount() { + const { dispatch } = this.props; + const params = { page: 1, filters: 'local,need_approval' }; + dispatch(fetchUsers(params)) + .then(() => this.setState({ isLoading: false })) + .catch(() => {}); + } + + handleApprove = nickname => { + const { dispatch } = this.props; + return e => { + dispatch(approveUsers([nickname])); + }; + } + + handleReject = nickname => { + const { dispatch } = this.props; + return e => { + dispatch(deleteUsers([nickname])); + }; + } + + render() { + const { intl, users } = this.props; + const { isLoading } = this.state; + + return ( + + + {users.map((user, i) => ( +
+
+
@{user.get('nickname')}
+
{user.get('registration_reason')}
+
+
+ + +
+
+ ))} +
+
+ ); + } + +} diff --git a/app/soapbox/features/admin/components/admin_nav.js b/app/soapbox/features/admin/components/admin_nav.js new file mode 100644 index 000000000..06aa236f5 --- /dev/null +++ b/app/soapbox/features/admin/components/admin_nav.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Icon from 'soapbox/components/icon'; +import IconWithCounter from 'soapbox/components/icon_with_counter'; +import { NavLink } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; + +const mapStateToProps = (state, props) => ({ + instance: state.get('instance'), + approvalCount: state.getIn(['admin', 'awaitingApproval']).count(), + reportsCount: state.getIn(['admin', 'open_report_count']), +}); + +export default @connect(mapStateToProps) +class AdminNav extends React.PureComponent { + + static propTypes = { + instance: ImmutablePropTypes.map.isRequired, + approvalCount: PropTypes.number, + reportsCount: PropTypes.number, + }; + + render() { + const { instance, approvalCount, reportsCount } = this.props; + + return ( + <> +
+
+ + + + + + + + + {instance.get('approval_required') && ( + + + + + )} + {!instance.get('registrations') && ( + {/* + + + */} + )} + {/* + + + */} +
+
+ {/*
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + +
+
*/} + + ); + } + +} diff --git a/app/soapbox/features/admin/index.js b/app/soapbox/features/admin/index.js new file mode 100644 index 000000000..eb91ff2a5 --- /dev/null +++ b/app/soapbox/features/admin/index.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import { parseVersion } from 'soapbox/utils/features'; + +const messages = defineMessages({ + heading: { id: 'column.admin.dashboard', defaultMessage: 'Dashboard' }, +}); + +const mapStateToProps = (state, props) => ({ + instance: state.get('instance'), + openReportCount: state.getIn(['admin', 'open_report_count']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Dashboard extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + instance: ImmutablePropTypes.map.isRequired, + openReportCount: PropTypes.number, + }; + + render() { + const { intl, instance } = this.props; + const v = parseVersion(instance.get('version')); + + return ( + +
+
+ +
+ +
+
+ +
+
+
+
+ +
+ +
+
+ +
+
+
+
+ +
+ +
+
+ +
+
+
+ {/* TODO: Awaiting approval users count */} +
+
+
+

+
    +
  • Soapbox FE 1.1.0
  • +
  • {v.software} {v.version}
  • +
+
+
+
+ ); + } + +} diff --git a/app/soapbox/features/ui/components/chats_counter_icon.js b/app/soapbox/features/ui/components/chats_counter_icon.js deleted file mode 100644 index bb6d32907..000000000 --- a/app/soapbox/features/ui/components/chats_counter_icon.js +++ /dev/null @@ -1,9 +0,0 @@ -import { connect } from 'react-redux'; -import IconWithBadge from 'soapbox/components/icon_with_badge'; - -const mapStateToProps = state => ({ - count: state.get('chats').reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0), - id: 'comment', -}); - -export default connect(mapStateToProps)(IconWithBadge); diff --git a/app/soapbox/features/ui/components/follow_requests_nav_link.js b/app/soapbox/features/ui/components/follow_requests_nav_link.js deleted file mode 100644 index 132637fa0..000000000 --- a/app/soapbox/features/ui/components/follow_requests_nav_link.js +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { fetchFollowRequests } from 'soapbox/actions/accounts'; -import { connect } from 'react-redux'; -import { NavLink, withRouter } from 'react-router-dom'; -import IconWithBadge from 'soapbox/components/icon_with_badge'; -import { List as ImmutableList } from 'immutable'; -import { FormattedMessage } from 'react-intl'; - -const mapStateToProps = state => { - const me = state.get('me'); - return { - locked: state.getIn(['accounts', me, 'locked']), - count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, - }; -}; - -export default @withRouter -@connect(mapStateToProps) -class FollowRequestsNavLink extends React.Component { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - locked: PropTypes.bool, - count: PropTypes.number.isRequired, - }; - - componentDidMount() { - const { dispatch, locked } = this.props; - - if (locked) { - dispatch(fetchFollowRequests()); - } - } - - render() { - const { locked, count } = this.props; - - if (!locked || count === 0) { - return null; - } - - return ; - } - -} diff --git a/app/soapbox/features/ui/components/notifications_counter_icon.js b/app/soapbox/features/ui/components/notifications_counter_icon.js deleted file mode 100644 index a2c3039cb..000000000 --- a/app/soapbox/features/ui/components/notifications_counter_icon.js +++ /dev/null @@ -1,9 +0,0 @@ -import { connect } from 'react-redux'; -import IconWithBadge from 'soapbox/components/icon_with_badge'; - -const mapStateToProps = state => ({ - count: state.getIn(['notifications', 'unread']), - id: 'bell', -}); - -export default connect(mapStateToProps)(IconWithBadge); diff --git a/app/soapbox/features/ui/components/reports_counter_icon.js b/app/soapbox/features/ui/components/reports_counter_icon.js deleted file mode 100644 index 14a121887..000000000 --- a/app/soapbox/features/ui/components/reports_counter_icon.js +++ /dev/null @@ -1,9 +0,0 @@ -import { connect } from 'react-redux'; -import IconWithBadge from 'soapbox/components/icon_with_badge'; - -const mapStateToProps = state => ({ - count: state.getIn(['admin', 'open_report_count']), - id: 'gavel', -}); - -export default connect(mapStateToProps)(IconWithBadge); diff --git a/app/soapbox/features/ui/components/tabs_bar.js b/app/soapbox/features/ui/components/tabs_bar.js index a87bf7429..013eef313 100644 --- a/app/soapbox/features/ui/components/tabs_bar.js +++ b/app/soapbox/features/ui/components/tabs_bar.js @@ -5,9 +5,7 @@ import { Link, NavLink, withRouter } from 'react-router-dom'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { connect } from 'react-redux'; import classNames from 'classnames'; -import NotificationsCounterIcon from './notifications_counter_icon'; -import ReportsCounterIcon from './reports_counter_icon'; -import ChatsCounterIcon from './chats_counter_icon'; +import IconWithCounter from 'soapbox/components/icon_with_counter'; import SearchContainer from 'soapbox/features/compose/containers/search_container'; import Avatar from '../../../components/avatar'; import ActionBar from 'soapbox/features/compose/components/action_bar'; @@ -32,6 +30,9 @@ class TabsBar extends React.PureComponent { onOpenSidebar: PropTypes.func.isRequired, logo: PropTypes.string, account: ImmutablePropTypes.map, + dashboardCount: PropTypes.number, + notificationCount: PropTypes.number, + chatsCount: PropTypes.number, } state = { @@ -52,7 +53,7 @@ class TabsBar extends React.PureComponent { } getNavLinks() { - const { intl: { formatMessage }, logo, account } = this.props; + const { intl: { formatMessage }, logo, account, dashboardCount, notificationCount, chatsCount } = this.props; let links = []; if (logo) { links.push( @@ -69,26 +70,23 @@ class TabsBar extends React.PureComponent { if (account) { links.push( - - + ); } if (account) { links.push( - - + ); } if (account && isStaff(account)) { links.push( - - - - - ); + + + + ); } links.push( @@ -156,9 +154,14 @@ class TabsBar extends React.PureComponent { const mapStateToProps = state => { const me = state.get('me'); + const reportsCount = state.getIn(['admin', 'open_report_count']); + const approvalCount = state.getIn(['admin', 'awaitingApproval']).count(); return { account: state.getIn(['accounts', me]), logo: getSoapboxConfig(state).get('logo'), + notificationCount: state.getIn(['notifications', 'unread']), + chatsCount: state.get('chats').reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0), + dashboardCount: reportsCount + approvalCount, }; }; diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 8b714fe7e..313f60607 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -16,7 +16,7 @@ import { debounce } from 'lodash'; import { uploadCompose, resetCompose } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; -import { fetchReports } from '../../actions/admin'; +import { fetchReports, fetchUsers } from '../../actions/admin'; import { fetchFilters } from '../../actions/filters'; import { fetchChats } from 'soapbox/actions/chats'; import { clearHeight } from '../../actions/height_cache'; @@ -39,6 +39,7 @@ import Icon from 'soapbox/components/icon'; import { isStaff } from 'soapbox/utils/accounts'; import ChatPanes from 'soapbox/features/chats/components/chat_panes'; import ProfileHoverCard from 'soapbox/components/profile_hover_card'; +import AdminNav from 'soapbox/features/admin/components/admin_nav'; import { Status, @@ -86,6 +87,8 @@ import { ChatIndex, ChatRoom, ServerInfo, + Dashboard, + AwaitingApproval, } from './util/async-components'; // Dummy import, to make sure that ends up in the application bundle. @@ -154,6 +157,14 @@ const LAYOUT = { , ], }, + ADMIN: { + LEFT: [ + , + ], + RIGHT: [ + , + ], + }, STATUS: { TOP: null, LEFT: null, @@ -274,6 +285,9 @@ class SwitchingColumnsArea extends React.PureComponent { + + + @@ -448,8 +462,10 @@ class UI extends React.PureComponent { this.props.dispatch(expandNotifications()); this.props.dispatch(fetchChats()); // this.props.dispatch(fetchGroups('member')); - if (isStaff(account)) + if (isStaff(account)) { this.props.dispatch(fetchReports({ state: 'open' })); + this.props.dispatch(fetchUsers({ page: 1, filters: 'local,need_approval' })); + } setTimeout(() => this.props.dispatch(fetchFilters()), 500); } diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index 3cb9e5142..6e313b4d8 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -217,3 +217,11 @@ export function ChatRoom() { export function ServerInfo() { return import(/* webpackChunkName: "features/server_info" */'../../server_info'); } + +export function Dashboard() { + return import(/* webpackChunkName: "features/admin" */'../../admin'); +} + +export function AwaitingApproval() { + return import(/* webpackChunkName: "features/admin/awaiting_approval" */'../../admin/awaiting_approval'); +} diff --git a/app/soapbox/reducers/__tests__/admin-test.js b/app/soapbox/reducers/__tests__/admin-test.js index aa32ace8f..891b8cabe 100644 --- a/app/soapbox/reducers/__tests__/admin-test.js +++ b/app/soapbox/reducers/__tests__/admin-test.js @@ -1,11 +1,13 @@ import reducer from '../admin'; -import { fromJS } from 'immutable'; +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; describe('admin reducer', () => { it('should return the initial state', () => { expect(reducer(undefined, {})).toEqual(fromJS({ reports: [], open_report_count: 0, + users: ImmutableMap(), + awaitingApproval: ImmutableOrderedSet(), })); }); }); diff --git a/app/soapbox/reducers/admin.js b/app/soapbox/reducers/admin.js index 2a41666f6..c7c6be507 100644 --- a/app/soapbox/reducers/admin.js +++ b/app/soapbox/reducers/admin.js @@ -1,11 +1,54 @@ -import { ADMIN_REPORTS_FETCH_SUCCESS } from '../actions/admin'; -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { + ADMIN_REPORTS_FETCH_SUCCESS, + ADMIN_USERS_FETCH_SUCCESS, + ADMIN_USERS_DELETE_REQUEST, + ADMIN_USERS_DELETE_SUCCESS, + ADMIN_USERS_APPROVE_REQUEST, + ADMIN_USERS_APPROVE_SUCCESS, +} from '../actions/admin'; +import { + Map as ImmutableMap, + List as ImmutableList, + OrderedSet as ImmutableOrderedSet, + fromJS, +} from 'immutable'; const initialState = ImmutableMap({ reports: ImmutableList(), + users: ImmutableMap(), open_report_count: 0, + awaitingApproval: ImmutableOrderedSet(), }); +function importUsers(state, users) { + return state.withMutations(state => { + users.forEach(user => { + if (user.approval_pending) { + state.update('awaitingApproval', orderedSet => orderedSet.add(user.nickname)); + } + state.setIn(['users', user.nickname], fromJS(user)); + }); + }); +} + +function deleteUsers(state, nicknames) { + return state.withMutations(state => { + nicknames.forEach(nickname => { + state.update('awaitingApproval', orderedSet => orderedSet.delete(nickname)); + state.deleteIn(['users', nickname]); + }); + }); +} + +function approveUsers(state, users) { + return state.withMutations(state => { + users.forEach(user => { + state.update('awaitingApproval', orderedSet => orderedSet.delete(user.nickname)); + state.setIn(['users', user.nickname], fromJS(user)); + }); + }); +} + export default function admin(state = initialState, action) { switch(action.type) { case ADMIN_REPORTS_FETCH_SUCCESS: @@ -16,6 +59,15 @@ export default function admin(state = initialState, action) { } else { return state.set('reports', fromJS(action.data.reports)); } + case ADMIN_USERS_FETCH_SUCCESS: + return importUsers(state, action.data.users); + case ADMIN_USERS_DELETE_REQUEST: + case ADMIN_USERS_DELETE_SUCCESS: + return deleteUsers(state, action.nicknames); + case ADMIN_USERS_APPROVE_REQUEST: + return state.update('awaitingApproval', set => set.subtract(action.nicknames)); + case ADMIN_USERS_APPROVE_SUCCESS: + return approveUsers(state, action.users); default: return state; } diff --git a/app/styles/application.scss b/app/styles/application.scss index 3841d231e..91f5ec192 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -79,6 +79,7 @@ @import 'components/snackbar'; @import 'components/accordion'; @import 'components/server-info'; +@import 'components/admin'; // Holiday @import 'holiday/halloween'; diff --git a/app/styles/components/admin.scss b/app/styles/components/admin.scss new file mode 100644 index 000000000..2712016e2 --- /dev/null +++ b/app/styles/components/admin.scss @@ -0,0 +1,96 @@ +.dashcounters { + display: flex; + flex-wrap: wrap; + margin: 0 -5px 0; + padding: 20px; +} + +.dashcounter { + box-sizing: border-box; + flex: 0 0 33.333%; + padding: 0 5px; + margin-bottom: 10px; + + > a, + > div { + text-decoration: none; + color: inherit; + display: block; + padding: 20px; + background: var(--accent-color--faint); + border-radius: 4px; + transition: 0.2s; + } + + > a:hover { + background: var(--accent-color--med); + transform: translateY(-2px); + } + + &__num, + &__text { + text-align: center; + font-weight: 500; + font-size: 24px; + line-height: 30px; + color: var(--primary-text-color); + margin-bottom: 10px; + } + + &__label { + font-size: 14px; + color: hsla(var(--primary-text-color_hsl), 0.6); + text-align: center; + font-weight: 500; + } +} + +.dashwidgets { + display: flex; + flex-wrap: wrap; + margin: 0 -5px; + padding: 0 20px 20px 20px; +} + +.dashwidget { + flex: 1; + margin-bottom: 20px; + padding: 0 5px; + + h4 { + text-transform: uppercase; + font-size: 13px; + font-weight: 700; + color: hsla(var(--primary-text-color_hsl), 0.6); + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid var(--accent-color--med); + } +} + +.unapproved-account { + padding: 15px 20px; + font-size: 14px; + display: flex; + + &__nickname { + font-weight: bold; + } + + &__reason { + padding: 5px 0 5px 15px; + border-left: 3px solid hsla(var(--primary-text-color_hsl), 0.4); + color: var(--primary-text-color--faint); + } + + &__actions { + margin-left: auto; + padding-left: 20px; + display: flex; + flex-wrap: nowrap; + } +} + +.slist .item-list article:nth-child(2n-1) .unapproved-account { + background-color: hsla(var(--accent-color_hsl), 0.07); +} diff --git a/app/styles/components/promo-panel.scss b/app/styles/components/promo-panel.scss index e2030cee6..78625e338 100644 --- a/app/styles/components/promo-panel.scss +++ b/app/styles/components/promo-panel.scss @@ -28,7 +28,8 @@ } } - &__icon { + &__icon, + .icon-with-counter { margin-right: 12px; } } diff --git a/app/styles/components/tabs-bar.scss b/app/styles/components/tabs-bar.scss index 8553b3df6..326f688dd 100644 --- a/app/styles/components/tabs-bar.scss +++ b/app/styles/components/tabs-bar.scss @@ -213,6 +213,12 @@ } } + .icon-with-counter__counter { + @media screen and (min-width: 895px) { + left: 5px; + } + } + &.optional { display: none; @media screen and (max-width: $nav-breakpoint-2) { diff --git a/app/styles/ui.scss b/app/styles/ui.scss index a2ca21a06..3f6637b49 100644 --- a/app/styles/ui.scss +++ b/app/styles/ui.scss @@ -699,3 +699,25 @@ text-align: center; } } + +.icon-with-counter { + position: relative; + display: inline; + + &__counter { + @include font-montserrat; + @include font-size(14); + @include line-height(14); + position: absolute; + box-sizing: border-box; + left: 8px; + top: -12px; + min-width: 16px; + height: 16px; + padding: 1px 3px 0; + border-radius: 8px; + text-align: center; + color: #fff; + background: var(--accent-color); + } +}