Merge branch 'admin-ui-reorganize' into 'develop'

Admin UI improvements

See merge request soapbox-pub/soapbox!2015
environments/review-develop-3zknud/deployments/1786
Alex Gleason 2 years ago
commit eb63ec4d46

@ -25,6 +25,7 @@ deps:
cache:
<<: *cache
policy: push
interruptible: true
danger:
stage: test

@ -38,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Composer: move emoji button alongside other composer buttons, add numerical counter.
- Birthdays: move today's birthdays out of notifications into right sidebar.
- Performance: improve scrolling/navigation between feeds by using a virtual window library.
- Admin: reorganize UI into 3-column layout.
- Admin: include external link to frontend repo for the running commit.
### Removed
- Theme: Halloween theme.

@ -0,0 +1,43 @@
import React from 'react';
import List, { ListItem } from './list';
interface IRadioGroup {
onChange: React.ChangeEventHandler
children: React.ReactElement<{ onChange: React.ChangeEventHandler }>[]
}
const RadioGroup = ({ onChange, children }: IRadioGroup) => {
const childrenWithProps = React.Children.map(children, child =>
React.cloneElement(child, { onChange }),
);
return <List>{childrenWithProps}</List>;
};
interface IRadioItem {
label: React.ReactNode,
hint?: React.ReactNode,
value: string,
checked: boolean,
onChange?: React.ChangeEventHandler,
}
const RadioItem: React.FC<IRadioItem> = ({ label, hint, checked = false, onChange, value }) => {
return (
<ListItem label={label} hint={hint}>
<input
type='radio'
checked={checked}
onChange={onChange}
value={value}
className='h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500'
/>
</ListItem>
);
};
export {
RadioGroup,
RadioItem,
};

@ -0,0 +1,57 @@
import React from 'react';
import { FormattedNumber } from 'react-intl';
import { Link } from 'react-router-dom';
import { Text } from 'soapbox/components/ui';
import { isNumber } from 'soapbox/utils/numbers';
interface IDashCounter {
count: number | undefined
label: React.ReactNode
to?: string
percent?: boolean
}
/** Displays a (potentially clickable) dashboard statistic. */
const DashCounter: React.FC<IDashCounter> = ({ count, label, to = '#', percent = false }) => {
if (!isNumber(count)) {
return null;
}
return (
<Link
className='bg-gray-200 dark:bg-gray-800 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer'
to={to}
>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber
value={count}
style={percent ? 'unit' : undefined}
unit={percent ? 'percent' : undefined}
/>
</Text>
<Text align='center'>
{label}
</Text>
</Link>
);
};
interface IDashCounters {
children: React.ReactNode
}
/** Wrapper container for dash counters. */
const DashCounters: React.FC<IDashCounters> = ({ children }) => {
return (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2'>
{children}
</div>
);
};
export {
DashCounter,
DashCounters,
};

@ -3,12 +3,7 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { updateConfig } from 'soapbox/actions/admin';
import snackbar from 'soapbox/actions/snackbar';
import {
SimpleForm,
FieldsGroup,
RadioGroup,
RadioItem,
} from 'soapbox/features/forms';
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import type { Instance } from 'soapbox/types/entities';
@ -54,33 +49,26 @@ const RegistrationModePicker: React.FC = () => {
};
return (
<SimpleForm>
<FieldsGroup>
<RadioGroup
label={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
onChange={onChange}
>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
checked={mode === 'open'}
value='open'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
checked={mode === 'approval'}
value='approval'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
checked={mode === 'closed'}
value='closed'
/>
</RadioGroup>
</FieldsGroup>
</SimpleForm>
<RadioGroup onChange={onChange}>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
checked={mode === 'open'}
value='open'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
checked={mode === 'approval'}
value='approval'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
checked={mode === 'closed'}
value='closed'
/>
</RadioGroup>
);
};

@ -4,7 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { approveUsers } from 'soapbox/actions/admin';
import { rejectUserModal } from 'soapbox/actions/moderation';
import snackbar from 'soapbox/actions/snackbar';
import IconButton from 'soapbox/components/icon-button';
import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
@ -45,16 +45,31 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
};
return (
<div className='unapproved-account'>
<div className='unapproved-account__bio'>
<div className='unapproved-account__nickname'>@{account.get('acct')}</div>
<blockquote className='md'>{adminAccount?.invite_request || ''}</blockquote>
</div>
<div className='unapproved-account__actions'>
<IconButton src={require('@tabler/icons/check.svg')} onClick={handleApprove} />
<IconButton src={require('@tabler/icons/x.svg')} onClick={handleReject} />
</div>
</div>
<HStack space={4} justifyContent='between'>
<Stack space={1}>
<Text weight='semibold'>
@{account.get('acct')}
</Text>
<Text tag='blockquote' size='sm'>
{adminAccount?.invite_request || ''}
</Text>
</Stack>
<HStack space={2} alignItems='center'>
<IconButton
src={require('@tabler/icons/check.svg')}
onClick={handleApprove}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
<IconButton
src={require('@tabler/icons/x.svg')}
onClick={handleReject}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
</HStack>
</HStack>
);
};

@ -3,8 +3,9 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl';
import { fetchModerationLog } from 'soapbox/actions/admin';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui';
import { Column, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { AdminLog } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' },
@ -18,6 +19,7 @@ const ModerationLog = () => {
const items = useAppSelector((state) => {
return state.admin_log.index.map((i) => state.admin_log.items.get(String(i)));
});
const hasMore = useAppSelector((state) => state.admin_log.total - state.admin_log.index.count() > 0);
const [isLoading, setIsLoading] = useState(true);
@ -54,26 +56,38 @@ const ModerationLog = () => {
emptyMessage={intl.formatMessage(messages.emptyMessage)}
hasMore={hasMore}
onLoadMore={handleLoadMore}
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
>
{items.map((item) => item && (
<div className='logentry' key={item.id}>
<div className='logentry__message'>{item.message}</div>
<div className='logentry__timestamp'>
<FormattedDate
value={new Date(item.time * 1000)}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</div>
</div>
{items.map(item => item && (
<LogItem key={item.id} log={item} />
))}
</ScrollableList>
</Column>
);
};
interface ILogItem {
log: AdminLog
}
const LogItem: React.FC<ILogItem> = ({ log }) => {
return (
<Stack space={2} className='p-4'>
<Text>{log.message}</Text>
<Text theme='muted' size='xs'>
<FormattedDate
value={new Date(log.time * 1000)}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</Text>
</Stack>
);
};
export default ModerationLog;

@ -33,9 +33,12 @@ const AwaitingApproval: React.FC = () => {
showLoading={showLoading}
scrollKey='awaiting-approval'
emptyMessage={intl.formatMessage(messages.emptyMessage)}
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
>
{accountIds.map(id => (
<UnapprovedAccount accountId={id} key={id} />
<div key={id} className='py-4 px-5'>
<UnapprovedAccount accountId={id} />
</div>
))}
</ScrollableList>
);

@ -1,19 +1,21 @@
import React from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { Link } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email-list';
import { Text } from 'soapbox/components/ui';
import List, { ListItem } from 'soapbox/components/list';
import { CardTitle, Icon, IconButton, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
import sourceCode from 'soapbox/utils/code';
import { download } from 'soapbox/utils/download';
import { parseVersion } from 'soapbox/utils/features';
import { isNumber } from 'soapbox/utils/numbers';
import { DashCounter, DashCounters } from '../components/dashcounter';
import RegistrationModePicker from '../components/registration-mode-picker';
const Dashboard: React.FC = () => {
const dispatch = useAppDispatch();
const history = useHistory();
const instance = useInstance();
const features = useFeatures();
const account = useOwnAccount();
@ -39,6 +41,9 @@ const Dashboard: React.FC = () => {
e.preventDefault();
};
const navigateToSoapboxConfig = () => history.push('/soapbox/config');
const navigateToModerationLog = () => history.push('/soapbox/admin/log');
const v = parseVersion(instance.version);
const userCount = instance.stats.get('user_count');
@ -46,87 +51,121 @@ const Dashboard: React.FC = () => {
const domainCount = instance.stats.get('domain_count');
const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined;
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : null;
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
if (!account) return null;
return (
<>
<div className='dashcounters mt-8'>
{isNumber(mau) && (
<div className='dashcounter'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={mau} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />
</Text>
</div>
)}
{isNumber(userCount) && (
<Link className='dashcounter' to='/soapbox/admin/users'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={userCount} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />
</Text>
</Link>
)}
{isNumber(retention) && (
<div className='dashcounter'>
<Text align='center' size='2xl' weight='medium'>
{retention}%
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />
</Text>
</div>
)}
{isNumber(statusCount) && (
<Link className='dashcounter' to='/timeline/local'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={statusCount} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />
</Text>
</Link>
)}
{isNumber(domainCount) && (
<div className='dashcounter'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={domainCount} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />
</Text>
</div>
)}
</div>
{account.admin && <RegistrationModePicker />}
<div className='dashwidgets'>
<div className='dashwidget'>
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
<ul>
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
<li>{v.software + (v.build ? `+${v.build}` : '')} <span className='pull-right'>{v.version}</span></li>
</ul>
</div>
{features.emailList && account.admin && (
<div className='dashwidget'>
<h4><FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' /></h4>
<ul>
<li><a href='#' onClick={handleSubscribersClick} target='_blank'>subscribers.csv</a></li>
<li><a href='#' onClick={handleUnsubscribersClick} target='_blank'>unsubscribers.csv</a></li>
<li><a href='#' onClick={handleCombinedClick} target='_blank'>combined.csv</a></li>
</ul>
</div>
<Stack space={6} className='mt-4'>
<DashCounters>
<DashCounter
count={mau}
label={<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />}
/>
<DashCounter
to='/soapbox/admin/users'
count={userCount}
label={<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />}
/>
<DashCounter
count={retention}
label={<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />}
percent
/>
<DashCounter
to='/timeline/local'
count={statusCount}
label={<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />}
/>
<DashCounter
count={domainCount}
label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
/>
</DashCounters>
<List>
{account.admin && (
<ListItem
onClick={navigateToSoapboxConfig}
label={<FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' />}
/>
)}
</div>
</>
<ListItem
onClick={navigateToModerationLog}
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
/>
</List>
{account.admin && (
<>
<CardTitle
title={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
/>
<RegistrationModePicker />
</>
)}
<CardTitle
title={<FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' />}
/>
<List>
<ListItem label={<FormattedMessage id='admin.software.frontend' defaultMessage='Frontend' />}>
<a
href={sourceCode.ref ? `${sourceCode.url}/tree/${sourceCode.ref}` : sourceCode.url}
className='flex space-x-1 items-center truncate'
target='_blank'
>
<span>{sourceCode.displayName} {sourceCode.version}</span>
<Icon
className='w-4 h-4'
src={require('@tabler/icons/external-link.svg')}
/>
</a>
</ListItem>
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
</ListItem>
</List>
{(features.emailList && account.admin) && (
<>
<CardTitle
title={<FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' />}
/>
<List>
<ListItem label='subscribers.csv'>
<IconButton
src={require('@tabler/icons/download.svg')}
onClick={handleSubscribersClick}
iconClassName='w-5 h-5'
/>
</ListItem>
<ListItem label='unsubscribers.csv'>
<IconButton
src={require('@tabler/icons/download.svg')}
onClick={handleUnsubscribersClick}
iconClassName='w-5 h-5'
/>
</ListItem>
<ListItem label='combined.csv'>
<IconButton
src={require('@tabler/icons/download.svg')}
onClick={handleCombinedClick}
iconClassName='w-5 h-5'
/>
</ListItem>
</List>
</>
)}
</Stack>
);
};

@ -1,8 +1,8 @@
import classNames from 'clsx';
import React, { useState, useRef } from 'react';
import React, { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { Text, Select } from '../../components/ui';
import { Select } from '../../components/ui';
interface IInputContainer {
label?: React.ReactNode,
@ -175,52 +175,6 @@ export const Checkbox: React.FC<ICheckbox> = (props) => (
<SimpleInput type='checkbox' {...props} />
);
interface IRadioGroup {
label?: React.ReactNode,
onChange?: React.ChangeEventHandler,
}
export const RadioGroup: React.FC<IRadioGroup> = (props) => {
const { label, children, onChange } = props;
const childrenWithProps = React.Children.map(children, child =>
// @ts-ignore
React.cloneElement(child, { onChange }),
);
return (
<div className='input with_floating_label radio_buttons'>
<div className='label_input'>
<label>{label}</label>
<ul>{childrenWithProps}</ul>
</div>
</div>
);
};
interface IRadioItem {
label?: React.ReactNode,
hint?: React.ReactNode,
value: string,
checked: boolean,
onChange?: React.ChangeEventHandler,
}
export const RadioItem: React.FC<IRadioItem> = (props) => {
const { current: id } = useRef<string>(uuidv4());
const { label, hint, checked = false, ...rest } = props;
return (
<li className='radio'>
<label htmlFor={id}>
<input id={id} type='radio' checked={checked} {...rest} />
<Text>{label}</Text>
{hint && <span className='hint'>{hint}</span>}
</label>
</li>
);
};
interface ISelectDropdown {
label?: React.ReactNode,
hint?: React.ReactNode,

@ -8,7 +8,7 @@ import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
import type { AnyAction } from 'redux';
const LogEntryRecord = ImmutableRecord({
export const LogEntryRecord = ImmutableRecord({
data: ImmutableMap<string, any>(),
id: 0,
message: '',

@ -24,10 +24,12 @@ import {
StatusRecord,
TagRecord,
} from 'soapbox/normalizers';
import { LogEntryRecord } from 'soapbox/reducers/admin-log';
import type { Record as ImmutableRecord } from 'immutable';
type AdminAccount = ReturnType<typeof AdminAccountRecord>;
type AdminLog = ReturnType<typeof LogEntryRecord>;
type AdminReport = ReturnType<typeof AdminReportRecord>;
type Announcement = ReturnType<typeof AnnouncementRecord>;
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
@ -68,6 +70,7 @@ type EmbeddedEntity<T extends object> = null | string | ReturnType<ImmutableReco
export {
AdminAccount,
AdminLog,
AdminReport,
Account,
Announcement,

@ -3,6 +3,8 @@ const { execSync } = require('child_process');
const pkg = require('../../../package.json');
const { CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_SHA } = process.env;
const shortRepoName = url => new URL(url).pathname.substring(1);
const trimHash = hash => hash.substring(0, 7);
@ -10,14 +12,12 @@ const tryGit = cmd => {
try {
return String(execSync(cmd));
} catch (e) {
return null;
return undefined;
}
};
const version = pkg => {
// Try to discern from GitLab CI first
const { CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_SHA } = process.env;
if (CI_COMMIT_TAG === `v${pkg.version}` || CI_COMMIT_REF_NAME === 'stable') {
return pkg.version;
}
@ -43,4 +43,5 @@ module.exports = {
repository: shortRepoName(pkg.repository.url),
version: version(pkg),
homepage: pkg.homepage,
ref: CI_COMMIT_TAG || CI_COMMIT_SHA || tryGit('git rev-parse HEAD'),
};

@ -50,7 +50,6 @@
@import 'components/profile-hover-card';
@import 'components/filters';
@import 'components/snackbar';
@import 'components/admin';
@import 'components/backups';
@import 'components/crypto-donate';
@import 'components/aliases';

@ -1,67 +0,0 @@
.dashcounters {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mb-4;
}
.dashcounter {
@apply bg-gray-200 dark:bg-gray-800 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer;
}
.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);
}
a {
color: var(--brand-color);
}
}
.unapproved-account {
padding: 15px 20px;
font-size: 14px;
display: flex;
&__nickname {
font-weight: bold;
}
&__actions {
margin-left: auto;
display: flex;
flex-wrap: nowrap;
column-gap: 10px;
padding-left: 20px;
.svg-icon {
height: 24px;
width: 24px;
}
}
}
.logentry {
padding: 15px;
&__timestamp {
color: var(--primary-text-color--faint);
font-size: 13px;
text-align: right;
}
}
Loading…
Cancel
Save