Merge remote-tracking branch 'soapbox/develop' into mastodon-groups
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
6
app/assets/sounds/LICENSE.md
Normal file
6
app/assets/sounds/LICENSE.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Sound licenses
|
||||
|
||||
- `chat.mp3`
|
||||
- `chat.oga`
|
||||
|
||||
© [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
@ -103,6 +103,19 @@ const updateConfig = (configs: Record<string, any>[]) =>
|
||||
});
|
||||
};
|
||||
|
||||
const updateSoapboxConfig = (data: Record<string, any>) =>
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
const params = [{
|
||||
group: ':pleroma',
|
||||
key: ':frontend_configurations',
|
||||
value: [{
|
||||
tuple: [':soapbox_fe', data],
|
||||
}],
|
||||
}];
|
||||
|
||||
return dispatch(updateConfig(params));
|
||||
};
|
||||
|
||||
const fetchMastodonReports = (params: Record<string, any>) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
api(getState)
|
||||
@ -585,6 +598,7 @@ export {
|
||||
ADMIN_USERS_UNSUGGEST_FAIL,
|
||||
fetchConfig,
|
||||
updateConfig,
|
||||
updateSoapboxConfig,
|
||||
fetchReports,
|
||||
closeReports,
|
||||
fetchUsers,
|
||||
|
||||
43
app/soapbox/components/radio.tsx
Normal file
43
app/soapbox/components/radio.tsx
Normal file
@ -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,
|
||||
};
|
||||
@ -41,6 +41,7 @@ export { default as PhoneInput } from './phone-input/phone-input';
|
||||
export { default as ProgressBar } from './progress-bar/progress-bar';
|
||||
export { default as RadioButton } from './radio-button/radio-button';
|
||||
export { default as Select } from './select/select';
|
||||
export { default as Slider } from './slider/slider';
|
||||
export { default as Spinner } from './spinner/spinner';
|
||||
export { default as Stack } from './stack/stack';
|
||||
export { default as Streamfield } from './streamfield/streamfield';
|
||||
|
||||
124
app/soapbox/components/ui/slider/slider.tsx
Normal file
124
app/soapbox/components/ui/slider/slider.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
type Point = { x: number, y: number };
|
||||
|
||||
interface ISlider {
|
||||
/** Value between 0 and 1. */
|
||||
value: number
|
||||
/** Callback when the value changes. */
|
||||
onChange(value: number): void
|
||||
}
|
||||
|
||||
/** Draggable slider component. */
|
||||
const Slider: React.FC<ISlider> = ({ value, onChange }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler = e => {
|
||||
document.addEventListener('mousemove', handleMouseSlide, true);
|
||||
document.addEventListener('mouseup', handleMouseUp, true);
|
||||
document.addEventListener('touchmove', handleMouseSlide, true);
|
||||
document.addEventListener('touchend', handleMouseUp, true);
|
||||
|
||||
handleMouseSlide(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseSlide, true);
|
||||
document.removeEventListener('mouseup', handleMouseUp, true);
|
||||
document.removeEventListener('touchmove', handleMouseSlide, true);
|
||||
document.removeEventListener('touchend', handleMouseUp, true);
|
||||
};
|
||||
|
||||
const handleMouseSlide = throttle(e => {
|
||||
if (node.current) {
|
||||
const { x } = getPointerPosition(node.current, e);
|
||||
|
||||
if (!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if (x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
onChange(slideamt);
|
||||
}
|
||||
}
|
||||
}, 60);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='inline-flex cursor-pointer h-6 relative transition'
|
||||
onMouseDown={handleMouseDown}
|
||||
ref={node}
|
||||
>
|
||||
<div className='w-full h-1 bg-primary-200 dark:bg-primary-700 absolute top-1/2 -translate-y-1/2 rounded-full' />
|
||||
<div className='h-1 bg-accent-500 absolute top-1/2 -translate-y-1/2 rounded-full' style={{ width: `${value * 100}%` }} />
|
||||
<span
|
||||
className='bg-accent-500 absolute rounded-full w-3 h-3 -ml-1.5 top-1/2 -translate-y-1/2 z-10 shadow'
|
||||
tabIndex={0}
|
||||
style={{ left: `${value * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const findElementPosition = (el: HTMLElement) => {
|
||||
let box;
|
||||
|
||||
if (el.getBoundingClientRect && el.parentNode) {
|
||||
box = el.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (!box) {
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const docEl = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
|
||||
const scrollLeft = window.pageXOffset || body.scrollLeft;
|
||||
const left = (box.left + scrollLeft) - clientLeft;
|
||||
|
||||
const clientTop = docEl.clientTop || body.clientTop || 0;
|
||||
const scrollTop = window.pageYOffset || body.scrollTop;
|
||||
const top = (box.top + scrollTop) - clientTop;
|
||||
|
||||
return {
|
||||
left: Math.round(left),
|
||||
top: Math.round(top),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
|
||||
const box = findElementPosition(el);
|
||||
const boxW = el.offsetWidth;
|
||||
const boxH = el.offsetHeight;
|
||||
const boxY = box.top;
|
||||
const boxX = box.left;
|
||||
|
||||
let pageY = event.pageY;
|
||||
let pageX = event.pageX;
|
||||
|
||||
if (event.changedTouches) {
|
||||
pageX = event.changedTouches[0].pageX;
|
||||
pageY = event.changedTouches[0].pageY;
|
||||
}
|
||||
|
||||
return {
|
||||
y: Math.max(0, Math.min(1, (pageY - boxY) / boxH)),
|
||||
x: Math.max(0, Math.min(1, (pageX - boxX) / boxW)),
|
||||
};
|
||||
};
|
||||
|
||||
export default Slider;
|
||||
@ -41,6 +41,7 @@ import {
|
||||
useInstance,
|
||||
} from 'soapbox/hooks';
|
||||
import MESSAGES from 'soapbox/locales/messages';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { useCachedLocationHandler } from 'soapbox/utils/redirect';
|
||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||
@ -267,8 +268,9 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
|
||||
const settings = useSettings();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const demo = !!settings.get('demo');
|
||||
const darkMode = useTheme() === 'dark';
|
||||
const themeCss = generateThemeCss(soapboxConfig);
|
||||
const themeCss = generateThemeCss(demo ? normalizeSoapboxConfig({ brandColor: '#0482d8' }) : soapboxConfig);
|
||||
|
||||
const bodyClass = classNames('bg-white dark:bg-gray-800 text-base h-full', {
|
||||
'no-reduce-motion': !settings.get('reduceMotion'),
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -1,221 +0,0 @@
|
||||
# Custom Locale Data
|
||||
|
||||
This folder is used to store custom locale data. These custom locale data are
|
||||
not yet provided by [Unicode Common Locale Data Repository](http://cldr.unicode.org/development/new-cldr-developers)
|
||||
and hence not provided in [react-intl/locale-data/*](https://github.com/yahoo/react-intl).
|
||||
|
||||
The locale data should support [Locale Data APIs](https://github.com/yahoo/react-intl/wiki/API#locale-data-apis)
|
||||
of the react-intl library.
|
||||
|
||||
It is recommended to start your custom locale data from this sample English
|
||||
locale data ([*](#plural-rules)):
|
||||
|
||||
```javascript
|
||||
/*eslint eqeqeq: "off"*/
|
||||
/*eslint no-nested-ternary: "off"*/
|
||||
|
||||
export default [
|
||||
{
|
||||
locale: "en",
|
||||
pluralRuleFunction: function(e, a) {
|
||||
var n = String(e).split("."),
|
||||
l = !n[1],
|
||||
o = Number(n[0]) == e,
|
||||
t = o && n[0].slice(-1),
|
||||
r = o && n[0].slice(-2);
|
||||
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
|
||||
},
|
||||
fields: {
|
||||
year: {
|
||||
displayName: "year",
|
||||
relative: {
|
||||
0: "this year",
|
||||
1: "next year",
|
||||
"-1": "last year"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} year",
|
||||
other: "in {0} years"
|
||||
},
|
||||
past: {
|
||||
one: "{0} year ago",
|
||||
other: "{0} years ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
month: {
|
||||
displayName: "month",
|
||||
relative: {
|
||||
0: "this month",
|
||||
1: "next month",
|
||||
"-1": "last month"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} month",
|
||||
other: "in {0} months"
|
||||
},
|
||||
past: {
|
||||
one: "{0} month ago",
|
||||
other: "{0} months ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
day: {
|
||||
displayName: "day",
|
||||
relative: {
|
||||
0: "today",
|
||||
1: "tomorrow",
|
||||
"-1": "yesterday"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} day",
|
||||
other: "in {0} days"
|
||||
},
|
||||
past: {
|
||||
one: "{0} day ago",
|
||||
other: "{0} days ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
hour: {
|
||||
displayName: "hour",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} hour",
|
||||
other: "in {0} hours"
|
||||
},
|
||||
past: {
|
||||
one: "{0} hour ago",
|
||||
other: "{0} hours ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
minute: {
|
||||
displayName: "minute",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} minute",
|
||||
other: "in {0} minutes"
|
||||
},
|
||||
past: {
|
||||
one: "{0} minute ago",
|
||||
other: "{0} minutes ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
second: {
|
||||
displayName: "second",
|
||||
relative: {
|
||||
0: "now"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} second",
|
||||
other: "in {0} seconds"
|
||||
},
|
||||
past: {
|
||||
one: "{0} second ago",
|
||||
other: "{0} seconds ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
### Plural Rules
|
||||
|
||||
The function `pluralRuleFunction()` should return the key to proper string of
|
||||
a plural form(s). The purpose of the function is to provide key of translate
|
||||
strings of correct plural form according. The different forms are described in
|
||||
[CLDR's Plural Rules][cldr-plural-rules],
|
||||
|
||||
[cldr-plural-rules]: http://cldr.unicode.org/index/cldr-spec/plural-rules
|
||||
|
||||
#### Quick Overview on CLDR Rules
|
||||
|
||||
Let's take English as an example.
|
||||
|
||||
When you describe a number, you can be either describe it as:
|
||||
* Cardinals: 1st, 2nd, 3rd ... 11th, 12th ... 21st, 22nd, 23nd ....
|
||||
* Ordinals: 1, 2, 3 ...
|
||||
|
||||
In any of these cases, the nouns will reflect the number with singular or plural
|
||||
form. For example:
|
||||
* in 0 days
|
||||
* in 1 day
|
||||
* in 2 days
|
||||
|
||||
The `pluralRuleFunction` receives 2 parameters:
|
||||
* `e`: a string representation of the number. Such as, "`1`", "`2`", "`2.1`".
|
||||
* `a`: `true` if this is "cardinal" type of description. `false` for ordinal and other case.
|
||||
|
||||
#### How you should write `pluralRuleFunction`
|
||||
|
||||
The first rule to write pluralRuleFunction is never translate the output string
|
||||
into your language. [Plural Rules][cldr-plural-rules] specified you should use
|
||||
these as the return values:
|
||||
|
||||
* "`zero`"
|
||||
* "`one`" (singular)
|
||||
* "`two`" (dual)
|
||||
* "`few`" (paucal)
|
||||
* "`many`" (also used for fractions if they have a separate class)
|
||||
* "`other`" (required—general plural form—also used if the language only has a single form)
|
||||
|
||||
Again, we'll use English as the example here.
|
||||
|
||||
Let's read the `return` statement in the pluralRuleFunction above:
|
||||
```javascript
|
||||
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
|
||||
```
|
||||
|
||||
This nested ternary is hard to read. It basically means:
|
||||
```javascript
|
||||
// e: the number variable to examine
|
||||
// a: "true" if cardinals
|
||||
// l: "true" if the variable e has nothin after decimal mark (e.g. "1.0" would be false)
|
||||
// o: "true" if the variable e is an integer
|
||||
// t: the "ones" of the number. e.g. "3" for number "9123"
|
||||
// r: the "ones" and "tens" of the number. e.g. "23" for number "9123"
|
||||
if (a == true) {
|
||||
if (t == 1 && r != 11) {
|
||||
return "one"; // i.e. 1st, 21st, 101st, 121st ...
|
||||
} else if (t == 2 && r != 12) {
|
||||
return "two"; // i.e. 2nd, 22nd, 102nd, 122nd ...
|
||||
} else if (t == 3 && r != 13) {
|
||||
return "few"; // i.e. 3rd, 23rd, 103rd, 123rd ...
|
||||
} else {
|
||||
return "other"; // i.e. 4th, 11th, 12th, 24th ...
|
||||
}
|
||||
} else {
|
||||
if (e == 1 && l) {
|
||||
return "one"; // i.e. 1 day
|
||||
} else {
|
||||
return "other"; // i.e. 0 days, 2 days, 3 days
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your language, like French, do not have complicated cardinal rules, you may
|
||||
use the French's version of it:
|
||||
```javascript
|
||||
function (e, a) {
|
||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
||||
}
|
||||
```
|
||||
|
||||
If your language, like Chinese, do not have any pluralization rule at all you
|
||||
may use the Chinese's version of it:
|
||||
```javascript
|
||||
function (e, a) {
|
||||
return "other";
|
||||
}
|
||||
```
|
||||
@ -1,108 +0,0 @@
|
||||
/*eslint eqeqeq: "off"*/
|
||||
/*eslint no-nested-ternary: "off"*/
|
||||
/*eslint quotes: "off"*/
|
||||
|
||||
export default [{
|
||||
locale: "co",
|
||||
pluralRuleFunction: function(e, a) {
|
||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
||||
},
|
||||
fields: {
|
||||
year: {
|
||||
displayName: "annu",
|
||||
relative: {
|
||||
0: "quist'annu",
|
||||
1: "l'annu chì vene",
|
||||
"-1": "l'annu passatu",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} annu",
|
||||
other: "in {0} anni",
|
||||
},
|
||||
past: {
|
||||
one: "{0} annu fà",
|
||||
other: "{0} anni fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
month: {
|
||||
displayName: "mese",
|
||||
relative: {
|
||||
0: "Questu mese",
|
||||
1: "u mese chì vene",
|
||||
"-1": "u mese passatu",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} mese",
|
||||
other: "in {0} mesi",
|
||||
},
|
||||
past: {
|
||||
one: "{0} mese fà",
|
||||
other: "{0} mesi fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
day: {
|
||||
displayName: "ghjornu",
|
||||
relative: {
|
||||
0: "oghje",
|
||||
1: "dumane",
|
||||
"-1": "eri",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} ghjornu",
|
||||
other: "in {0} ghjornu",
|
||||
},
|
||||
past: {
|
||||
one: "{0} ghjornu fà",
|
||||
other: "{0} ghjorni fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
hour: {
|
||||
displayName: "ora",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} ora",
|
||||
other: "in {0} ore",
|
||||
},
|
||||
past: {
|
||||
one: "{0} ora fà",
|
||||
other: "{0} ore fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
minute: {
|
||||
displayName: "minuta",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} minuta",
|
||||
other: "in {0} minute",
|
||||
},
|
||||
past: {
|
||||
one: "{0} minuta fà",
|
||||
other: "{0} minute fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
second: {
|
||||
displayName: "siconda",
|
||||
relative: {
|
||||
0: "avà",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} siconda",
|
||||
other: "in {0} siconde",
|
||||
},
|
||||
past: {
|
||||
one: "{0} siconda fà",
|
||||
other: "{0} siconde fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}];
|
||||
@ -1,108 +0,0 @@
|
||||
/*eslint eqeqeq: "off"*/
|
||||
/*eslint no-nested-ternary: "off"*/
|
||||
/*eslint quotes: "off"*/
|
||||
|
||||
export default [{
|
||||
locale: "oc",
|
||||
pluralRuleFunction: function(e, a) {
|
||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
||||
},
|
||||
fields: {
|
||||
year: {
|
||||
displayName: "an",
|
||||
relative: {
|
||||
0: "ongan",
|
||||
1: "l'an que ven",
|
||||
"-1": "l'an passat",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} an",
|
||||
other: "d’aquí {0} ans",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} an",
|
||||
other: "fa {0} ans",
|
||||
},
|
||||
},
|
||||
},
|
||||
month: {
|
||||
displayName: "mes",
|
||||
relative: {
|
||||
0: "aqueste mes",
|
||||
1: "lo mes que ven",
|
||||
"-1": "lo mes passat",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} mes",
|
||||
other: "d’aquí {0} meses",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} mes",
|
||||
other: "fa {0} meses",
|
||||
},
|
||||
},
|
||||
},
|
||||
day: {
|
||||
displayName: "jorn",
|
||||
relative: {
|
||||
0: "uèi",
|
||||
1: "deman",
|
||||
"-1": "ièr",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} jorn",
|
||||
other: "d’aquí {0} jorns",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} jorn",
|
||||
other: "fa {0} jorns",
|
||||
},
|
||||
},
|
||||
},
|
||||
hour: {
|
||||
displayName: "ora",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} ora",
|
||||
other: "d’aquí {0} oras",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} ora",
|
||||
other: "fa {0} oras",
|
||||
},
|
||||
},
|
||||
},
|
||||
minute: {
|
||||
displayName: "minuta",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} minuta",
|
||||
other: "d’aquí {0} minutas",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} minuta",
|
||||
other: "fa {0} minutas",
|
||||
},
|
||||
},
|
||||
},
|
||||
second: {
|
||||
displayName: "segonda",
|
||||
relative: {
|
||||
0: "ara",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} segonda",
|
||||
other: "d’aquí {0} segondas",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} segonda",
|
||||
other: "fa {0} segondas",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}];
|
||||
@ -43,7 +43,6 @@ const DEFAULT_COLORS = ImmutableMap<string, any>({
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}),
|
||||
'sea-blue': '#2feecc',
|
||||
'greentext': '#789922',
|
||||
});
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const LogEntryRecord = ImmutableRecord({
|
||||
export const LogEntryRecord = ImmutableRecord({
|
||||
data: ImmutableMap<string, any>(),
|
||||
id: 0,
|
||||
message: '',
|
||||
|
||||
@ -26,10 +26,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>;
|
||||
@ -72,6 +74,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'),
|
||||
};
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
/** Download the file from the response instead of opening it in a tab. */
|
||||
// https://stackoverflow.com/a/53230807
|
||||
export const download = (response: AxiosResponse, filename: string) => {
|
||||
const url = URL.createObjectURL(new Blob([response.data]));
|
||||
export const download = (data: string, filename: string): void => {
|
||||
const url = URL.createObjectURL(new Blob([data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
|
||||
@ -116,3 +116,18 @@ export const colorsToCss = (colors: TailwindColorPalette): string => {
|
||||
export const generateThemeCss = (soapboxConfig: SoapboxConfig): string => {
|
||||
return colorsToCss(soapboxConfig.colors.toJS() as TailwindColorPalette);
|
||||
};
|
||||
|
||||
const hexToHsl = (hex: string): Hsl | null => {
|
||||
const rgb = hexToRgb(hex);
|
||||
return rgb ? rgbToHsl(rgb) : null;
|
||||
};
|
||||
|
||||
export const hueShift = (hex: string, delta: number): string => {
|
||||
const { h, s, l } = hexToHsl(hex)!;
|
||||
|
||||
return hslToHex({
|
||||
h: (h + delta) % 360,
|
||||
s,
|
||||
l,
|
||||
});
|
||||
};
|
||||
@ -48,7 +48,6 @@
|
||||
@import 'components/audio-player';
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@ -70,7 +70,6 @@ body,
|
||||
--dark-blue: #1d1953;
|
||||
--electric-blue: #5448ee;
|
||||
--electric-blue-contrast: #e8e7fd;
|
||||
--sea-blue: #2feecc;
|
||||
|
||||
// Sizes
|
||||
--border-radius-base: 4px;
|
||||
|
||||
Reference in New Issue
Block a user