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:
<<: *cache <<: *cache
policy: push policy: push
interruptible: true
danger: danger:
stage: test 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. - Composer: move emoji button alongside other composer buttons, add numerical counter.
- Birthdays: move today's birthdays out of notifications into right sidebar. - Birthdays: move today's birthdays out of notifications into right sidebar.
- Performance: improve scrolling/navigation between feeds by using a virtual window library. - 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 ### Removed
- Theme: Halloween theme. - 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 { updateConfig } from 'soapbox/actions/admin';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { import { RadioGroup, RadioItem } from 'soapbox/components/radio';
SimpleForm,
FieldsGroup,
RadioGroup,
RadioItem,
} from 'soapbox/features/forms';
import { useAppDispatch, useInstance } from 'soapbox/hooks'; import { useAppDispatch, useInstance } from 'soapbox/hooks';
import type { Instance } from 'soapbox/types/entities'; import type { Instance } from 'soapbox/types/entities';
@ -54,12 +49,7 @@ const RegistrationModePicker: React.FC = () => {
}; };
return ( return (
<SimpleForm> <RadioGroup onChange={onChange}>
<FieldsGroup>
<RadioGroup
label={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
onChange={onChange}
>
<RadioItem <RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />} label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />} hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
@ -79,8 +69,6 @@ const RegistrationModePicker: React.FC = () => {
value='closed' value='closed'
/> />
</RadioGroup> </RadioGroup>
</FieldsGroup>
</SimpleForm>
); );
}; };

@ -4,7 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { approveUsers } from 'soapbox/actions/admin'; import { approveUsers } from 'soapbox/actions/admin';
import { rejectUserModal } from 'soapbox/actions/moderation'; import { rejectUserModal } from 'soapbox/actions/moderation';
import snackbar from 'soapbox/actions/snackbar'; 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 { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
@ -45,16 +45,31 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
}; };
return ( return (
<div className='unapproved-account'> <HStack space={4} justifyContent='between'>
<div className='unapproved-account__bio'> <Stack space={1}>
<div className='unapproved-account__nickname'>@{account.get('acct')}</div> <Text weight='semibold'>
<blockquote className='md'>{adminAccount?.invite_request || ''}</blockquote> @{account.get('acct')}
</div> </Text>
<div className='unapproved-account__actions'> <Text tag='blockquote' size='sm'>
<IconButton src={require('@tabler/icons/check.svg')} onClick={handleApprove} /> {adminAccount?.invite_request || ''}
<IconButton src={require('@tabler/icons/x.svg')} onClick={handleReject} /> </Text>
</div> </Stack>
</div>
<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 { fetchModerationLog } from 'soapbox/actions/admin';
import ScrollableList from 'soapbox/components/scrollable-list'; 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 { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { AdminLog } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' }, heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' },
@ -18,6 +19,7 @@ const ModerationLog = () => {
const items = useAppSelector((state) => { const items = useAppSelector((state) => {
return state.admin_log.index.map((i) => state.admin_log.items.get(String(i))); 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 hasMore = useAppSelector((state) => state.admin_log.total - state.admin_log.index.count() > 0);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -54,13 +56,28 @@ const ModerationLog = () => {
emptyMessage={intl.formatMessage(messages.emptyMessage)} emptyMessage={intl.formatMessage(messages.emptyMessage)}
hasMore={hasMore} hasMore={hasMore}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
> >
{items.map((item) => item && ( {items.map(item => item && (
<div className='logentry' key={item.id}> <LogItem key={item.id} log={item} />
<div className='logentry__message'>{item.message}</div> ))}
<div className='logentry__timestamp'> </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 <FormattedDate
value={new Date(item.time * 1000)} value={new Date(log.time * 1000)}
hour12 hour12
year='numeric' year='numeric'
month='short' month='short'
@ -68,11 +85,8 @@ const ModerationLog = () => {
hour='numeric' hour='numeric'
minute='2-digit' minute='2-digit'
/> />
</div> </Text>
</div> </Stack>
))}
</ScrollableList>
</Column>
); );
}; };

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

@ -1,19 +1,21 @@
import React from 'react'; import React from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email-list'; 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 { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
import { download } from 'soapbox/utils/download'; import { download } from 'soapbox/utils/download';
import { parseVersion } from 'soapbox/utils/features'; 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'; import RegistrationModePicker from '../components/registration-mode-picker';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const history = useHistory();
const instance = useInstance(); const instance = useInstance();
const features = useFeatures(); const features = useFeatures();
const account = useOwnAccount(); const account = useOwnAccount();
@ -39,6 +41,9 @@ const Dashboard: React.FC = () => {
e.preventDefault(); e.preventDefault();
}; };
const navigateToSoapboxConfig = () => history.push('/soapbox/config');
const navigateToModerationLog = () => history.push('/soapbox/admin/log');
const v = parseVersion(instance.version); const v = parseVersion(instance.version);
const userCount = instance.stats.get('user_count'); const userCount = instance.stats.get('user_count');
@ -46,87 +51,121 @@ const Dashboard: React.FC = () => {
const domainCount = instance.stats.get('domain_count'); const domainCount = instance.stats.get('domain_count');
const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined; 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; if (!account) return null;
return ( return (
<> <Stack space={6} className='mt-4'>
<div className='dashcounters mt-8'> <DashCounters>
{isNumber(mau) && ( <DashCounter
<div className='dashcounter'> count={mau}
<Text align='center' size='2xl' weight='medium'> label={<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />}
<FormattedNumber value={mau} /> />
</Text> <DashCounter
<Text align='center'> to='/soapbox/admin/users'
<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' /> count={userCount}
</Text> label={<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />}
</div> />
)} <DashCounter
{isNumber(userCount) && ( count={retention}
<Link className='dashcounter' to='/soapbox/admin/users'> label={<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />}
<Text align='center' size='2xl' weight='medium'> percent
<FormattedNumber value={userCount} /> />
</Text> <DashCounter
<Text align='center'> to='/timeline/local'
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' /> count={statusCount}
</Text> label={<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />}
</Link> />
)} <DashCounter
{isNumber(retention) && ( count={domainCount}
<div className='dashcounter'> label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
<Text align='center' size='2xl' weight='medium'> />
{retention}% </DashCounters>
</Text>
<Text align='center'> <List>
<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' /> {account.admin && (
</Text> <ListItem
</div> onClick={navigateToSoapboxConfig}
)} label={<FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' />}
{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>
<ListItem
{account.admin && <RegistrationModePicker />} onClick={navigateToModerationLog}
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
<div className='dashwidgets'> />
<div className='dashwidget'> </List>
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
<ul> {account.admin && (
<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> <CardTitle
</ul> title={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
</div> />
{features.emailList && account.admin && (
<div className='dashwidget'> <RegistrationModePicker />
<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>
)} )}
</div>
<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 classNames from 'clsx';
import React, { useState, useRef } from 'react'; import React, { useState } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Text, Select } from '../../components/ui'; import { Select } from '../../components/ui';
interface IInputContainer { interface IInputContainer {
label?: React.ReactNode, label?: React.ReactNode,
@ -175,52 +175,6 @@ export const Checkbox: React.FC<ICheckbox> = (props) => (
<SimpleInput type='checkbox' {...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 { interface ISelectDropdown {
label?: React.ReactNode, label?: React.ReactNode,
hint?: React.ReactNode, hint?: React.ReactNode,

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

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

@ -3,6 +3,8 @@ const { execSync } = require('child_process');
const pkg = require('../../../package.json'); 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 shortRepoName = url => new URL(url).pathname.substring(1);
const trimHash = hash => hash.substring(0, 7); const trimHash = hash => hash.substring(0, 7);
@ -10,14 +12,12 @@ const tryGit = cmd => {
try { try {
return String(execSync(cmd)); return String(execSync(cmd));
} catch (e) { } catch (e) {
return null; return undefined;
} }
}; };
const version = pkg => { const version = pkg => {
// Try to discern from GitLab CI first // 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') { if (CI_COMMIT_TAG === `v${pkg.version}` || CI_COMMIT_REF_NAME === 'stable') {
return pkg.version; return pkg.version;
} }
@ -43,4 +43,5 @@ module.exports = {
repository: shortRepoName(pkg.repository.url), repository: shortRepoName(pkg.repository.url),
version: version(pkg), version: version(pkg),
homepage: pkg.homepage, 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/profile-hover-card';
@import 'components/filters'; @import 'components/filters';
@import 'components/snackbar'; @import 'components/snackbar';
@import 'components/admin';
@import 'components/backups'; @import 'components/backups';
@import 'components/crypto-donate'; @import 'components/crypto-donate';
@import 'components/aliases'; @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