Merge branch 'admin-dashboard' into 'develop'
Soapbox admin See merge request soapbox-pub/soapbox-fe!403
This commit is contained in:
commit
e7bb78712a
20 changed files with 562 additions and 113 deletions
|
@ -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_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS';
|
||||||
export const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL';
|
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) {
|
export function updateAdminConfig(params) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST });
|
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 });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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 (
|
|
||||||
<i className='icon-with-badge'>
|
|
||||||
{count > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(count)}</i>}
|
|
||||||
</i>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
IconWithBadge.propTypes = {
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
count: PropTypes.number.isRequired,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IconWithBadge;
|
|
23
app/soapbox/components/icon_with_counter.js
Normal file
23
app/soapbox/components/icon_with_counter.js
Normal file
|
@ -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 (
|
||||||
|
<div className='icon-with-counter'>
|
||||||
|
<Icon id={icon} fixedWidth={fixedWidth} />
|
||||||
|
{count > 0 && <i className='icon-with-counter__counter'>
|
||||||
|
{shortNumberFormat(count)}
|
||||||
|
</i>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
IconWithCounter.propTypes = {
|
||||||
|
icon: PropTypes.string.isRequired,
|
||||||
|
count: PropTypes.number.isRequired,
|
||||||
|
fixedWidth: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IconWithCounter;
|
83
app/soapbox/features/admin/awaiting_approval.js
Normal file
83
app/soapbox/features/admin/awaiting_approval.js
Normal file
|
@ -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 (
|
||||||
|
<Column icon='user' heading={intl.formatMessage(messages.heading)} backBtnSlim>
|
||||||
|
<ScrollableList isLoading={isLoading} showLoading={isLoading} scrollKey='awaiting-approval' emptyMessage={intl.formatMessage(messages.emptyMessage)}>
|
||||||
|
{users.map((user, i) => (
|
||||||
|
<div className='unapproved-account' key={user.get('id')}>
|
||||||
|
<div className='unapproved-account__bio'>
|
||||||
|
<div className='unapproved-account__nickname'>@{user.get('nickname')}</div>
|
||||||
|
<blockquote className='unapproved-account__reason'>{user.get('registration_reason')}</blockquote>
|
||||||
|
</div>
|
||||||
|
<div className='unapproved-account__actions'>
|
||||||
|
<IconButton icon='check' onClick={this.handleApprove(user.get('nickname'))} />
|
||||||
|
<IconButton icon='close' onClick={this.handleReject(user.get('nickname'))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
98
app/soapbox/features/admin/components/admin_nav.js
Normal file
98
app/soapbox/features/admin/components/admin_nav.js
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className='wtf-panel promo-panel'>
|
||||||
|
<div className='promo-panel__container'>
|
||||||
|
<NavLink className='promo-panel-item' to='/admin'>
|
||||||
|
<Icon id='tachometer' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.dashboard' defaultMessage='Dashboard' />
|
||||||
|
</NavLink>
|
||||||
|
<a className='promo-panel-item' href='/pleroma/admin/#/reports/index' target='_blank'>
|
||||||
|
<IconWithCounter icon='gavel' count={reportsCount} fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.reports' defaultMessage='Reports' />
|
||||||
|
</a>
|
||||||
|
{instance.get('approval_required') && (
|
||||||
|
<NavLink className='promo-panel-item' to='/admin/approval'>
|
||||||
|
<IconWithCounter icon='user' count={approvalCount} fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.awaiting_approval' defaultMessage='Awaiting Approval' />
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{!instance.get('registrations') && (
|
||||||
|
{/* <NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='envelope' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.invites' defaultMessage='Invites' />
|
||||||
|
</NavLink> */}
|
||||||
|
)}
|
||||||
|
{/* <NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='group' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.registration' defaultMessage='Registration' />
|
||||||
|
</NavLink> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <div className='wtf-panel promo-panel'>
|
||||||
|
<div className='promo-panel__container'>
|
||||||
|
<NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='info-circle' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.site_profile' defaultMessage='Site Profile' />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='paint-brush' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.branding' defaultMessage='Branding' />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='bars' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.menus' defaultMessage='Menus' />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='file-o' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.pages' defaultMessage='Pages' />
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='wtf-panel promo-panel'>
|
||||||
|
<div className='promo-panel__container'>
|
||||||
|
<NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='fediverse' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.mrf' defaultMessage='Federation' />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className='promo-panel-item' to='#'>
|
||||||
|
<Icon id='filter' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.filtering' defaultMessage='Filtering' />
|
||||||
|
</NavLink>
|
||||||
|
<a className='promo-panel-item' href='/pleroma/admin/#/settings/index' target='_blank'>
|
||||||
|
<Icon id='code' className='promo-panel-item__icon' fixedWidth />
|
||||||
|
<FormattedMessage id='admin_nav.advanced' defaultMessage='Advanced' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
81
app/soapbox/features/admin/index.js
Normal file
81
app/soapbox/features/admin/index.js
Normal file
|
@ -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 (
|
||||||
|
<Column icon='tachometer' heading={intl.formatMessage(messages.heading)} backBtnSlim>
|
||||||
|
<div className='dashcounters'>
|
||||||
|
<div className='dashcounter'>
|
||||||
|
<a href='/pleroma/admin/#/users/index' target='_blank'>
|
||||||
|
<div className='dashcounter__num'>
|
||||||
|
<FormattedNumber value={instance.getIn(['stats', 'user_count'])} />
|
||||||
|
</div>
|
||||||
|
<div className='dashcounter__label'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='users' />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className='dashcounter'>
|
||||||
|
<a href='/pleroma/admin/#/statuses/index' target='_blank'>
|
||||||
|
<div className='dashcounter__num'>
|
||||||
|
<FormattedNumber value={instance.getIn(['stats', 'status_count'])} />
|
||||||
|
</div>
|
||||||
|
<div className='dashcounter__label'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className='dashcounter'>
|
||||||
|
<a href='/pleroma/admin/#/reports/index' target='_blank'>
|
||||||
|
<div className='dashcounter__num'>
|
||||||
|
<FormattedNumber value={this.props.openReportCount} />
|
||||||
|
</div>
|
||||||
|
<div className='dashcounter__label'>
|
||||||
|
<FormattedMessage id='admin.dashcounters.open_report_count_label' defaultMessage='open reports' />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/* TODO: Awaiting approval users count */}
|
||||||
|
</div>
|
||||||
|
<div className='dashwidgets'>
|
||||||
|
<div class='dashwidget'>
|
||||||
|
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
||||||
|
<ul>
|
||||||
|
<li>Soapbox FE <span class='pull-right'>1.1.0</span></li>
|
||||||
|
<li>{v.software} <span class='pull-right'>{v.version}</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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);
|
|
|
@ -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 <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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);
|
|
|
@ -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);
|
|
|
@ -5,9 +5,7 @@ import { Link, NavLink, withRouter } from 'react-router-dom';
|
||||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import NotificationsCounterIcon from './notifications_counter_icon';
|
import IconWithCounter from 'soapbox/components/icon_with_counter';
|
||||||
import ReportsCounterIcon from './reports_counter_icon';
|
|
||||||
import ChatsCounterIcon from './chats_counter_icon';
|
|
||||||
import SearchContainer from 'soapbox/features/compose/containers/search_container';
|
import SearchContainer from 'soapbox/features/compose/containers/search_container';
|
||||||
import Avatar from '../../../components/avatar';
|
import Avatar from '../../../components/avatar';
|
||||||
import ActionBar from 'soapbox/features/compose/components/action_bar';
|
import ActionBar from 'soapbox/features/compose/components/action_bar';
|
||||||
|
@ -32,6 +30,9 @@ class TabsBar extends React.PureComponent {
|
||||||
onOpenSidebar: PropTypes.func.isRequired,
|
onOpenSidebar: PropTypes.func.isRequired,
|
||||||
logo: PropTypes.string,
|
logo: PropTypes.string,
|
||||||
account: ImmutablePropTypes.map,
|
account: ImmutablePropTypes.map,
|
||||||
|
dashboardCount: PropTypes.number,
|
||||||
|
notificationCount: PropTypes.number,
|
||||||
|
chatsCount: PropTypes.number,
|
||||||
}
|
}
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -52,7 +53,7 @@ class TabsBar extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
getNavLinks() {
|
getNavLinks() {
|
||||||
const { intl: { formatMessage }, logo, account } = this.props;
|
const { intl: { formatMessage }, logo, account, dashboardCount, notificationCount, chatsCount } = this.props;
|
||||||
let links = [];
|
let links = [];
|
||||||
if (logo) {
|
if (logo) {
|
||||||
links.push(
|
links.push(
|
||||||
|
@ -69,26 +70,23 @@ class TabsBar extends React.PureComponent {
|
||||||
if (account) {
|
if (account) {
|
||||||
links.push(
|
links.push(
|
||||||
<NavLink key='notifications' className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications'>
|
<NavLink key='notifications' className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications'>
|
||||||
<Icon id='bell' />
|
<IconWithCounter icon='bell' count={notificationCount} />
|
||||||
<NotificationsCounterIcon />
|
|
||||||
<span><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></span>
|
<span><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></span>
|
||||||
</NavLink>);
|
</NavLink>);
|
||||||
}
|
}
|
||||||
if (account) {
|
if (account) {
|
||||||
links.push(
|
links.push(
|
||||||
<NavLink key='chats' className='tabs-bar__link tabs-bar__link--chats' to='/chats' data-preview-title-id='column.chats'>
|
<NavLink key='chats' className='tabs-bar__link tabs-bar__link--chats' to='/chats' data-preview-title-id='column.chats'>
|
||||||
<Icon id='comment' />
|
<IconWithCounter icon='comment' count={chatsCount} />
|
||||||
<ChatsCounterIcon />
|
|
||||||
<span><FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' /></span>
|
<span><FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' /></span>
|
||||||
</NavLink>);
|
</NavLink>);
|
||||||
}
|
}
|
||||||
if (account && isStaff(account)) {
|
if (account && isStaff(account)) {
|
||||||
links.push(
|
links.push(
|
||||||
<a key='reports' className='tabs-bar__link' href='/pleroma/admin/#/reports/index' target='_blank' data-preview-title-id='tabs_bar.reports'>
|
<NavLink key='dashboard' className='tabs-bar__link' to='/admin' data-preview-title-id='tabs_bar.dashboard'>
|
||||||
<Icon id='gavel' />
|
<IconWithCounter icon='tachometer' count={dashboardCount} />
|
||||||
<ReportsCounterIcon />
|
<span><FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' /></span>
|
||||||
<span><FormattedMessage id='tabs_bar.reports' defaultMessage='Reports' /></span>
|
</NavLink>);
|
||||||
</a>);
|
|
||||||
}
|
}
|
||||||
links.push(
|
links.push(
|
||||||
<NavLink key='search' className='tabs-bar__link tabs-bar__link--search' to='/search' data-preview-title-id='tabs_bar.search'>
|
<NavLink key='search' className='tabs-bar__link tabs-bar__link--search' to='/search' data-preview-title-id='tabs_bar.search'>
|
||||||
|
@ -156,9 +154,14 @@ class TabsBar extends React.PureComponent {
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const me = state.get('me');
|
const me = state.get('me');
|
||||||
|
const reportsCount = state.getIn(['admin', 'open_report_count']);
|
||||||
|
const approvalCount = state.getIn(['admin', 'awaitingApproval']).count();
|
||||||
return {
|
return {
|
||||||
account: state.getIn(['accounts', me]),
|
account: state.getIn(['accounts', me]),
|
||||||
logo: getSoapboxConfig(state).get('logo'),
|
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { debounce } from 'lodash';
|
||||||
import { uploadCompose, resetCompose } from '../../actions/compose';
|
import { uploadCompose, resetCompose } from '../../actions/compose';
|
||||||
import { expandHomeTimeline } from '../../actions/timelines';
|
import { expandHomeTimeline } from '../../actions/timelines';
|
||||||
import { expandNotifications } from '../../actions/notifications';
|
import { expandNotifications } from '../../actions/notifications';
|
||||||
import { fetchReports } from '../../actions/admin';
|
import { fetchReports, fetchUsers } from '../../actions/admin';
|
||||||
import { fetchFilters } from '../../actions/filters';
|
import { fetchFilters } from '../../actions/filters';
|
||||||
import { fetchChats } from 'soapbox/actions/chats';
|
import { fetchChats } from 'soapbox/actions/chats';
|
||||||
import { clearHeight } from '../../actions/height_cache';
|
import { clearHeight } from '../../actions/height_cache';
|
||||||
|
@ -39,6 +39,7 @@ import Icon from 'soapbox/components/icon';
|
||||||
import { isStaff } from 'soapbox/utils/accounts';
|
import { isStaff } from 'soapbox/utils/accounts';
|
||||||
import ChatPanes from 'soapbox/features/chats/components/chat_panes';
|
import ChatPanes from 'soapbox/features/chats/components/chat_panes';
|
||||||
import ProfileHoverCard from 'soapbox/components/profile_hover_card';
|
import ProfileHoverCard from 'soapbox/components/profile_hover_card';
|
||||||
|
import AdminNav from 'soapbox/features/admin/components/admin_nav';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Status,
|
Status,
|
||||||
|
@ -86,6 +87,8 @@ import {
|
||||||
ChatIndex,
|
ChatIndex,
|
||||||
ChatRoom,
|
ChatRoom,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
|
Dashboard,
|
||||||
|
AwaitingApproval,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
|
|
||||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||||
|
@ -154,6 +157,14 @@ const LAYOUT = {
|
||||||
<FeaturesPanel key='0' />,
|
<FeaturesPanel key='0' />,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
ADMIN: {
|
||||||
|
LEFT: [
|
||||||
|
<AdminNav key='0' />,
|
||||||
|
],
|
||||||
|
RIGHT: [
|
||||||
|
<LinkFooter key='1' />,
|
||||||
|
],
|
||||||
|
},
|
||||||
STATUS: {
|
STATUS: {
|
||||||
TOP: null,
|
TOP: null,
|
||||||
LEFT: null,
|
LEFT: null,
|
||||||
|
@ -274,6 +285,9 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
<WrappedRoute path='/settings/import' layout={LAYOUT.DEFAULT} component={ImportData} content={children} />
|
<WrappedRoute path='/settings/import' layout={LAYOUT.DEFAULT} component={ImportData} content={children} />
|
||||||
<WrappedRoute path='/soapbox/config' layout={LAYOUT.DEFAULT} component={SoapboxConfig} content={children} />
|
<WrappedRoute path='/soapbox/config' layout={LAYOUT.DEFAULT} component={SoapboxConfig} content={children} />
|
||||||
|
|
||||||
|
<Redirect from='/admin/dashboard' to='/admin' exact />
|
||||||
|
<WrappedRoute path='/admin' layout={LAYOUT.ADMIN} component={Dashboard} content={children} exact />
|
||||||
|
<WrappedRoute path='/admin/approval' layout={LAYOUT.ADMIN} component={AwaitingApproval} content={children} exact />
|
||||||
<WrappedRoute path='/info' layout={LAYOUT.EMPTY} component={ServerInfo} content={children} />
|
<WrappedRoute path='/info' layout={LAYOUT.EMPTY} component={ServerInfo} content={children} />
|
||||||
|
|
||||||
<WrappedRoute layout={LAYOUT.EMPTY} component={GenericNotFound} content={children} />
|
<WrappedRoute layout={LAYOUT.EMPTY} component={GenericNotFound} content={children} />
|
||||||
|
@ -448,8 +462,10 @@ class UI extends React.PureComponent {
|
||||||
this.props.dispatch(expandNotifications());
|
this.props.dispatch(expandNotifications());
|
||||||
this.props.dispatch(fetchChats());
|
this.props.dispatch(fetchChats());
|
||||||
// this.props.dispatch(fetchGroups('member'));
|
// this.props.dispatch(fetchGroups('member'));
|
||||||
if (isStaff(account))
|
if (isStaff(account)) {
|
||||||
this.props.dispatch(fetchReports({ state: 'open' }));
|
this.props.dispatch(fetchReports({ state: 'open' }));
|
||||||
|
this.props.dispatch(fetchUsers({ page: 1, filters: 'local,need_approval' }));
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
|
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
|
||||||
}
|
}
|
||||||
|
|
|
@ -217,3 +217,11 @@ export function ChatRoom() {
|
||||||
export function ServerInfo() {
|
export function ServerInfo() {
|
||||||
return import(/* webpackChunkName: "features/server_info" */'../../server_info');
|
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');
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import reducer from '../admin';
|
import reducer from '../admin';
|
||||||
import { fromJS } from 'immutable';
|
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||||
|
|
||||||
describe('admin reducer', () => {
|
describe('admin reducer', () => {
|
||||||
it('should return the initial state', () => {
|
it('should return the initial state', () => {
|
||||||
expect(reducer(undefined, {})).toEqual(fromJS({
|
expect(reducer(undefined, {})).toEqual(fromJS({
|
||||||
reports: [],
|
reports: [],
|
||||||
open_report_count: 0,
|
open_report_count: 0,
|
||||||
|
users: ImmutableMap(),
|
||||||
|
awaitingApproval: ImmutableOrderedSet(),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,54 @@
|
||||||
import { ADMIN_REPORTS_FETCH_SUCCESS } from '../actions/admin';
|
import {
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
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({
|
const initialState = ImmutableMap({
|
||||||
reports: ImmutableList(),
|
reports: ImmutableList(),
|
||||||
|
users: ImmutableMap(),
|
||||||
open_report_count: 0,
|
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) {
|
export default function admin(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ADMIN_REPORTS_FETCH_SUCCESS:
|
case ADMIN_REPORTS_FETCH_SUCCESS:
|
||||||
|
@ -16,6 +59,15 @@ export default function admin(state = initialState, action) {
|
||||||
} else {
|
} else {
|
||||||
return state.set('reports', fromJS(action.data.reports));
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,6 +79,7 @@
|
||||||
@import 'components/snackbar';
|
@import 'components/snackbar';
|
||||||
@import 'components/accordion';
|
@import 'components/accordion';
|
||||||
@import 'components/server-info';
|
@import 'components/server-info';
|
||||||
|
@import 'components/admin';
|
||||||
|
|
||||||
// Holiday
|
// Holiday
|
||||||
@import 'holiday/halloween';
|
@import 'holiday/halloween';
|
||||||
|
|
96
app/styles/components/admin.scss
Normal file
96
app/styles/components/admin.scss
Normal file
|
@ -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);
|
||||||
|
}
|
|
@ -28,7 +28,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon,
|
||||||
|
.icon-with-counter {
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -213,6 +213,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-with-counter__counter {
|
||||||
|
@media screen and (min-width: 895px) {
|
||||||
|
left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.optional {
|
&.optional {
|
||||||
display: none;
|
display: none;
|
||||||
@media screen and (max-width: $nav-breakpoint-2) {
|
@media screen and (max-width: $nav-breakpoint-2) {
|
||||||
|
|
|
@ -699,3 +699,25 @@
|
||||||
text-align: center;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue