Merge remote-tracking branch 'soapbox/develop' into mastodon-groups
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
@@ -526,11 +526,11 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||
};
|
||||
|
||||
const renderMessageButton = () => {
|
||||
if (features.chatsWithFollowers) { // Truth Social
|
||||
if (!ownAccount || !account || account.id === ownAccount?.id) {
|
||||
return null;
|
||||
}
|
||||
if (!ownAccount || !account || account.id === ownAccount?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (features.chatsWithFollowers) { // Truth Social
|
||||
const canChat = account.relationship?.followed_by;
|
||||
if (!canChat) {
|
||||
return null;
|
||||
|
||||
57
app/soapbox/features/admin/components/dashcounter.tsx
Normal file
57
app/soapbox/features/admin/components/dashcounter.tsx
Normal file
@@ -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,44 +1,49 @@
|
||||
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();
|
||||
|
||||
const handleSubscribersClick: React.MouseEventHandler = e => {
|
||||
dispatch(getSubscribersCsv()).then((response) => {
|
||||
download(response, 'subscribers.csv');
|
||||
dispatch(getSubscribersCsv()).then(({ data }) => {
|
||||
download(data, 'subscribers.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleUnsubscribersClick: React.MouseEventHandler = e => {
|
||||
dispatch(getUnsubscribersCsv()).then((response) => {
|
||||
download(response, 'unsubscribers.csv');
|
||||
dispatch(getUnsubscribersCsv()).then(({ data }) => {
|
||||
download(data, 'unsubscribers.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleCombinedClick: React.MouseEventHandler = e => {
|
||||
dispatch(getCombinedCsv()).then((response) => {
|
||||
download(response, 'combined.csv');
|
||||
dispatch(getCombinedCsv()).then(({ data }) => {
|
||||
download(data, 'combined.csv');
|
||||
}).catch(() => {});
|
||||
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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const Welcome = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className='py-20 px-4 sm:px-0' data-testid='chats-welcome'>
|
||||
<Stack className='py-20 px-4 sm:px-0 h-full overflow-y-auto' data-testid='chats-welcome'>
|
||||
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-2.5'>
|
||||
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
|
||||
{intl.formatMessage(messages.title, { br: <br /> })}
|
||||
|
||||
@@ -103,6 +103,13 @@ const SettingsStore: React.FC = () => {
|
||||
</CardHeader>
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='preferences.fields.demo_label' defaultMessage='Demo mode' />}
|
||||
hint={<FormattedMessage id='preferences.fields.demo_hint' defaultMessage='Use the default Soapbox logo and color scheme. Useful for taking screenshots.' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['demo']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.notifications.advanced' defaultMessage='Show all notification categories' />}>
|
||||
<SettingToggle settings={settings} settingPath={['notifications', 'quickFilter', 'advanced']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
@@ -102,8 +102,8 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
||||
};
|
||||
|
||||
const handleExportClick = () => {
|
||||
dispatch(fetchEventIcs(status.id)).then((response) => {
|
||||
download(response, 'calendar.ics');
|
||||
dispatch(fetchEventIcs(status.id)).then(({ data }) => {
|
||||
download(data, 'calendar.ics');
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useOwnAccount } from 'soapbox/hooks';
|
||||
import { useUpdateCredentials } from 'soapbox/queries/accounts';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'settings.messages.label', defaultMessage: 'Allow users you follow to start a new chat with you' },
|
||||
label: { id: 'settings.messages.label', defaultMessage: 'Allow users to start a new chat with you' },
|
||||
});
|
||||
|
||||
const MessagesSettings = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
@@ -106,7 +106,7 @@ const Settings = () => {
|
||||
{features.chats ? (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title='Direct Messages' />
|
||||
<CardTitle title={<FormattedMessage id='column.chats' defaultMessage='Chats' />} />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
|
||||
@@ -9,12 +9,12 @@ import ColorPicker from './color-picker';
|
||||
import type { ColorChangeHandler } from 'react-color';
|
||||
|
||||
interface IColorWithPicker {
|
||||
buttonId: string,
|
||||
value: string,
|
||||
onChange: ColorChangeHandler,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
const ColorWithPicker: React.FC<IColorWithPicker> = ({ buttonId, value, onChange }) => {
|
||||
const ColorWithPicker: React.FC<IColorWithPicker> = ({ value, onChange, className }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [placement, setPlacement] = useState<string | null>(null);
|
||||
@@ -39,11 +39,10 @@ const ColorWithPicker: React.FC<IColorWithPicker> = ({ buttonId, value, onChange
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={className}>
|
||||
<div
|
||||
ref={node}
|
||||
id={buttonId}
|
||||
className='w-8 h-8 rounded-md'
|
||||
className='w-full h-full'
|
||||
role='presentation'
|
||||
style={{ background: value }}
|
||||
title={value}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { updateConfig } from 'soapbox/actions/admin';
|
||||
import { updateSoapboxConfig } from 'soapbox/actions/admin';
|
||||
import { uploadMedia } from 'soapbox/actions/media';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
@@ -25,14 +26,11 @@ import ThemeSelector from 'soapbox/features/ui/components/theme-selector';
|
||||
import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
|
||||
import ColorWithPicker from './components/color-with-picker';
|
||||
import CryptoAddressInput from './components/crypto-address-input';
|
||||
import FooterLinkInput from './components/footer-link-input';
|
||||
import PromoPanelInput from './components/promo-panel-input';
|
||||
import SitePreview from './components/site-preview';
|
||||
|
||||
import type { ColorChangeHandler, ColorResult } from 'react-color';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' },
|
||||
saved: { id: 'soapbox_config.saved', defaultMessage: 'Soapbox config saved!' },
|
||||
@@ -59,7 +57,6 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
type ValueGetter<T = Element> = (e: React.ChangeEvent<T>) => any;
|
||||
type ColorValueGetter = (color: ColorResult, event: React.ChangeEvent<HTMLInputElement>) => any;
|
||||
type Template = ImmutableMap<string, any>;
|
||||
type ConfigPath = Array<string | number>;
|
||||
type ThemeChangeHandler = (theme: string) => void;
|
||||
@@ -72,6 +69,7 @@ const templates: Record<string, Template> = {
|
||||
|
||||
const SoapboxConfig: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const features = useFeatures();
|
||||
@@ -84,6 +82,8 @@ const SoapboxConfig: React.FC = () => {
|
||||
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2));
|
||||
const [jsonValid, setJsonValid] = useState(true);
|
||||
|
||||
const navigateToThemeEditor = () => history.push('/soapbox/admin/theme');
|
||||
|
||||
const soapbox = useMemo(() => {
|
||||
return normalizeSoapboxConfig(data);
|
||||
}, [data]);
|
||||
@@ -99,18 +99,8 @@ const SoapboxConfig: React.FC = () => {
|
||||
setJsonValid(true);
|
||||
};
|
||||
|
||||
const getParams = () => {
|
||||
return [{
|
||||
group: ':pleroma',
|
||||
key: ':frontend_configurations',
|
||||
value: [{
|
||||
tuple: [':soapbox_fe', data.toJS()],
|
||||
}],
|
||||
}];
|
||||
};
|
||||
|
||||
const handleSubmit: React.FormEventHandler = (e) => {
|
||||
dispatch(updateConfig(getParams())).then(() => {
|
||||
dispatch(updateSoapboxConfig(data.toJS())).then(() => {
|
||||
setLoading(false);
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
|
||||
}).catch(() => {
|
||||
@@ -132,12 +122,6 @@ const SoapboxConfig: React.FC = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const handleColorChange = (path: ConfigPath, getValue: ColorValueGetter): ColorChangeHandler => {
|
||||
return (color, event) => {
|
||||
setConfig(path, getValue(color, event));
|
||||
};
|
||||
};
|
||||
|
||||
const handleFileChange = (path: ConfigPath): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
return e => {
|
||||
const data = new FormData();
|
||||
@@ -224,21 +208,10 @@ const SoapboxConfig: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='soapbox_config.fields.brand_color_label' defaultMessage='Brand color' />}>
|
||||
<ColorWithPicker
|
||||
buttonId='brandColor'
|
||||
value={soapbox.brandColor}
|
||||
onChange={handleColorChange(['brandColor'], (color) => color.hex)}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='soapbox_config.fields.accent_color_label' defaultMessage='Accent color' />}>
|
||||
<ColorWithPicker
|
||||
buttonId='accentColor'
|
||||
value={soapbox.accentColor}
|
||||
onChange={handleColorChange(['accentColor'], (color) => color.hex)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='soapbox_config.fields.edit_theme_label' defaultMessage='Edit theme' />}
|
||||
onClick={navigateToThemeEditor}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<CardHeader>
|
||||
|
||||
28
app/soapbox/features/theme-editor/components/color.tsx
Normal file
28
app/soapbox/features/theme-editor/components/color.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
|
||||
|
||||
import type { ColorChangeHandler } from 'react-color';
|
||||
|
||||
interface IColor {
|
||||
color: string,
|
||||
onChange: (color: string) => void,
|
||||
}
|
||||
|
||||
/** Color input. */
|
||||
const Color: React.FC<IColor> = ({ color, onChange }) => {
|
||||
|
||||
const handleChange: ColorChangeHandler = (result) => {
|
||||
onChange(result.hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<ColorWithPicker
|
||||
className='w-full h-full'
|
||||
value={color}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Color;
|
||||
67
app/soapbox/features/theme-editor/components/palette.tsx
Normal file
67
app/soapbox/features/theme-editor/components/palette.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { HStack, Stack, Slider } from 'soapbox/components/ui';
|
||||
import { usePrevious } from 'soapbox/hooks';
|
||||
import { compareId } from 'soapbox/utils/comparators';
|
||||
import { hueShift } from 'soapbox/utils/theme';
|
||||
|
||||
import Color from './color';
|
||||
|
||||
interface ColorGroup {
|
||||
[tint: string]: string,
|
||||
}
|
||||
|
||||
interface IPalette {
|
||||
palette: ColorGroup,
|
||||
onChange: (palette: ColorGroup) => void,
|
||||
resetKey?: string,
|
||||
}
|
||||
|
||||
/** Editable color palette. */
|
||||
const Palette: React.FC<IPalette> = ({ palette, onChange, resetKey }) => {
|
||||
const tints = Object.keys(palette).sort(compareId);
|
||||
|
||||
const [hue, setHue] = useState(0);
|
||||
const lastHue = usePrevious(hue);
|
||||
|
||||
const handleChange = (tint: string) => {
|
||||
return (color: string) => {
|
||||
onChange({
|
||||
...palette,
|
||||
[tint]: color,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const delta = hue - (lastHue || 0);
|
||||
|
||||
const adjusted = Object.entries(palette).reduce<ColorGroup>((result, [tint, hex]) => {
|
||||
result[tint] = hueShift(hex, delta * 360);
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
onChange(adjusted);
|
||||
}, [hue]);
|
||||
|
||||
useEffect(() => {
|
||||
setHue(0);
|
||||
}, [resetKey]);
|
||||
|
||||
return (
|
||||
<Stack className='w-full'>
|
||||
<HStack className='h-8 rounded-md overflow-hidden'>
|
||||
{tints.map(tint => (
|
||||
<Color color={palette[tint]} onChange={handleChange(tint)} />
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
<Slider value={hue} onChange={setHue} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Palette as default,
|
||||
ColorGroup,
|
||||
};
|
||||
273
app/soapbox/features/theme-editor/index.tsx
Normal file
273
app/soapbox/features/theme-editor/index.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { updateSoapboxConfig } from 'soapbox/actions/admin';
|
||||
import { getHost } from 'soapbox/actions/instance';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Button, Column, Form, FormActions } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
|
||||
import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
|
||||
import Palette, { ColorGroup } from './components/palette';
|
||||
|
||||
import type { ColorChangeHandler } from 'react-color';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'admin.theme.title', defaultMessage: 'Theme' },
|
||||
saved: { id: 'theme_editor.saved', defaultMessage: 'Theme updated!' },
|
||||
restore: { id: 'theme_editor.restore', defaultMessage: 'Restore default theme' },
|
||||
export: { id: 'theme_editor.export', defaultMessage: 'Export theme' },
|
||||
import: { id: 'theme_editor.import', defaultMessage: 'Import theme' },
|
||||
importSuccess: { id: 'theme_editor.import_success', defaultMessage: 'Theme was successfully imported!' },
|
||||
});
|
||||
|
||||
interface IThemeEditor {
|
||||
}
|
||||
|
||||
/** UI for editing Tailwind theme colors. */
|
||||
const ThemeEditor: React.FC<IThemeEditor> = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const soapbox = useSoapboxConfig();
|
||||
const host = useAppSelector(state => getHost(state));
|
||||
const rawConfig = useAppSelector(state => state.soapbox);
|
||||
|
||||
const [colors, setColors] = useState(soapbox.colors.toJS() as any);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [resetKey, setResetKey] = useState(uuidv4());
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const updateColors = (key: string) => {
|
||||
return (newColors: ColorGroup) => {
|
||||
setColors({
|
||||
...colors,
|
||||
[key]: {
|
||||
...colors[key],
|
||||
...newColors,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const updateColor = (key: string) => {
|
||||
return (hex: string) => {
|
||||
setColors({
|
||||
...colors,
|
||||
[key]: hex,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const setTheme = (theme: any) => {
|
||||
setResetKey(uuidv4());
|
||||
setTimeout(() => setColors(theme));
|
||||
};
|
||||
|
||||
const resetTheme = () => {
|
||||
setTheme(soapbox.colors.toJS() as any);
|
||||
};
|
||||
|
||||
const updateTheme = async () => {
|
||||
const params = rawConfig.set('colors', colors).toJS();
|
||||
await dispatch(updateSoapboxConfig(params));
|
||||
};
|
||||
|
||||
const restoreDefaultTheme = () => {
|
||||
const colors = normalizeSoapboxConfig({ brandColor: '#0482d8' }).colors.toJS();
|
||||
setTheme(colors);
|
||||
};
|
||||
|
||||
const exportTheme = () => {
|
||||
const data = JSON.stringify(colors, null, 2);
|
||||
download(data, 'theme.json');
|
||||
};
|
||||
|
||||
const importTheme = () => {
|
||||
fileInput.current?.click();
|
||||
};
|
||||
|
||||
const handleSelectFile: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||
const file = e.target.files?.item(0);
|
||||
|
||||
if (file) {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
const colors = normalizeSoapboxConfig({ colors: json }).colors.toJS();
|
||||
|
||||
setTheme(colors);
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.importSuccess)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async() => {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await dispatch(fetchSoapboxConfig(host));
|
||||
await updateTheme();
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
|
||||
setSubmitting(false);
|
||||
} catch (e) {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<List>
|
||||
<PaletteListItem
|
||||
label='Primary'
|
||||
palette={colors.primary}
|
||||
onChange={updateColors('primary')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Secondary'
|
||||
palette={colors.secondary}
|
||||
onChange={updateColors('secondary')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Accent'
|
||||
palette={colors.accent}
|
||||
onChange={updateColors('accent')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Gray'
|
||||
palette={colors.gray}
|
||||
onChange={updateColors('gray')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Success'
|
||||
palette={colors.success}
|
||||
onChange={updateColors('success')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Danger'
|
||||
palette={colors.danger}
|
||||
onChange={updateColors('danger')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ColorListItem
|
||||
label='Greentext'
|
||||
value={colors.greentext}
|
||||
onChange={updateColor('greentext')}
|
||||
/>
|
||||
|
||||
<ColorListItem
|
||||
label='Accent Blue'
|
||||
value={colors['accent-blue']}
|
||||
onChange={updateColor('accent-blue')}
|
||||
/>
|
||||
|
||||
<ColorListItem
|
||||
label='Gradient Start'
|
||||
value={colors['gradient-start']}
|
||||
onChange={updateColor('gradient-start')}
|
||||
/>
|
||||
|
||||
<ColorListItem
|
||||
label='Gradient End'
|
||||
value={colors['gradient-end']}
|
||||
onChange={updateColor('gradient-end')}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<FormActions>
|
||||
<DropdownMenuContainer
|
||||
items={[{
|
||||
text: intl.formatMessage(messages.restore),
|
||||
action: restoreDefaultTheme,
|
||||
icon: require('@tabler/icons/refresh.svg'),
|
||||
},{
|
||||
text: intl.formatMessage(messages.import),
|
||||
action: importTheme,
|
||||
icon: require('@tabler/icons/upload.svg'),
|
||||
}, {
|
||||
text: intl.formatMessage(messages.export),
|
||||
action: exportTheme,
|
||||
icon: require('@tabler/icons/download.svg'),
|
||||
}]}
|
||||
/>
|
||||
<Button theme='secondary' onClick={resetTheme}>
|
||||
<FormattedMessage id='theme_editor.Reset' defaultMessage='Reset' />
|
||||
</Button>
|
||||
|
||||
<Button type='submit' theme='primary' disabled={submitting}>
|
||||
<FormattedMessage id='theme_editor.save' defaultMessage='Save theme' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={fileInput}
|
||||
multiple
|
||||
accept='application/json'
|
||||
className='hidden'
|
||||
onChange={handleSelectFile}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPaletteListItem {
|
||||
label: React.ReactNode,
|
||||
palette: ColorGroup,
|
||||
onChange: (palette: ColorGroup) => void,
|
||||
resetKey?: string,
|
||||
}
|
||||
|
||||
/** Palette editor inside a ListItem. */
|
||||
const PaletteListItem: React.FC<IPaletteListItem> = ({ label, palette, onChange, resetKey }) => {
|
||||
return (
|
||||
<ListItem label={<div className='w-20'>{label}</div>}>
|
||||
<Palette palette={palette} onChange={onChange} resetKey={resetKey} />
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface IColorListItem {
|
||||
label: React.ReactNode,
|
||||
value: string,
|
||||
onChange: (hex: string) => void,
|
||||
}
|
||||
|
||||
/** Single-color picker. */
|
||||
const ColorListItem: React.FC<IColorListItem> = ({ label, value, onChange }) => {
|
||||
const handleChange: ColorChangeHandler = (color, _e) => {
|
||||
onChange(color.hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem label={label}>
|
||||
<ColorWithPicker
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className='w-10 h-8 rounded-md overflow-hidden'
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeEditor;
|
||||
@@ -45,7 +45,7 @@ class Bundle extends React.PureComponent<BundleProps, BundleState> {
|
||||
this.load(this.props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: BundleProps) {
|
||||
UNSAFE_componentWillReceiveProps(nextProps: BundleProps) {
|
||||
if (nextProps.fetchComponent !== this.props.fetchComponent) {
|
||||
this.load(nextProps);
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
TestTimeline,
|
||||
LogoutPage,
|
||||
AuthTokenList,
|
||||
ThemeEditor,
|
||||
Quotes,
|
||||
ServiceWorkerInfo,
|
||||
EventInformation,
|
||||
@@ -312,6 +313,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
||||
<WrappedRoute path='/soapbox/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/soapbox/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
|
||||
<WrappedRoute path='/soapbox/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
|
||||
<WrappedRoute path='/soapbox/admin/theme' staffOnly page={AdminPage} component={ThemeEditor} content={children} exact />
|
||||
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
|
||||
|
||||
<WrappedRoute path='/developers/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} />
|
||||
|
||||
@@ -310,6 +310,10 @@ export function ModerationLog() {
|
||||
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation-log');
|
||||
}
|
||||
|
||||
export function ThemeEditor() {
|
||||
return import(/* webpackChunkName: "features/theme-editor" */'../../theme-editor');
|
||||
}
|
||||
|
||||
export function UserPanel() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/user-panel');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user