Switch to workspace

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-08-28 12:46:03 +02:00
parent 694abcb489
commit 4d5690d0c1
1318 changed files with 12005 additions and 11618 deletions

View File

@ -0,0 +1,40 @@
import React from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useRouteMatch } from 'react-router-dom';
import { Tabs } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
dashboard: { id: 'admin_nav.dashboard', defaultMessage: 'Dashboard' },
reports: { id: 'admin_nav.reports', defaultMessage: 'Reports' },
waitlist: { id: 'admin_nav.awaiting_approval', defaultMessage: 'Waitlist' },
});
const AdminTabs: React.FC = () => {
const intl = useIntl();
const match = useRouteMatch();
const approvalCount = useAppSelector(state => state.admin.awaitingApproval.count());
const reportsCount = useAppSelector(state => state.admin.openReports.count());
const tabs = [{
name: '/soapbox/admin',
text: intl.formatMessage(messages.dashboard),
to: '/soapbox/admin',
}, {
name: '/soapbox/admin/reports',
text: intl.formatMessage(messages.reports),
to: '/soapbox/admin/reports',
count: reportsCount,
}, {
name: '/soapbox/admin/approval',
text: intl.formatMessage(messages.waitlist),
to: '/soapbox/admin/approval',
count: approvalCount,
}];
return <Tabs items={tabs} activeItem={match.path} />;
};
export { AdminTabs as default };

View File

@ -0,0 +1,56 @@
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='flex cursor-pointer flex-col items-center space-y-2 rounded bg-gray-200 p-4 transition-transform hover:-translate-y-1 dark:bg-gray-800'
to={to}
>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber
value={count}
style={percent ? 'unit' : undefined}
unit={percent ? 'percent' : undefined}
numberingSystem='latn'
/>
</Text>
<Text align='center'>
{label}
</Text>
</Link>
);
};
interface IDashCounters {
children: React.ReactNode;
}
/** Wrapper container for dash counters. */
const DashCounters: React.FC<IDashCounters> = ({ children }) => (
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3'>
{children}
</div>
);
export {
DashCounter,
DashCounters,
};

View File

@ -0,0 +1,55 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { fetchUsers } from 'soapbox/actions/admin';
import { Widget } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' },
expand: { id: 'admin.latest_accounts_panel.more', defaultMessage: 'Click to see {count, plural, one {# account} other {# accounts}}' },
});
interface ILatestAccountsPanel {
limit?: number;
}
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const accountIds = useAppSelector<ImmutableOrderedSet<string>>((state) => state.admin.get('latestUsers').take(limit));
const [total, setTotal] = useState<number | undefined>(accountIds.size);
useEffect(() => {
dispatch(fetchUsers({
origin: 'local',
status: 'active',
limit,
})).then((value) => {
setTotal(value.total);
}).catch(() => {});
}, []);
const handleAction = () => {
history.push('/soapbox/admin/users');
};
return (
<Widget
title={intl.formatMessage(messages.title)}
onActionClick={handleAction}
actionTitle={intl.formatMessage(messages.expand, { count: total })}
>
{accountIds.take(limit).map((account) => (
<AccountContainer key={account} id={account} withRelationship={false} withDate />
))}
</Widget>
);
};
export { LatestAccountsPanel as default };

View File

@ -0,0 +1,74 @@
import { Instance } from 'pl-api';
import React from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { updateConfig } from 'soapbox/actions/admin';
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import toast from 'soapbox/toast';
type RegistrationMode = 'open' | 'approval' | 'closed';
const messages = defineMessages({
saved: { id: 'admin.dashboard.settings_saved', defaultMessage: 'Settings saved!' },
});
const generateConfig = (mode: RegistrationMode) => {
const configMap = {
open: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', false] }],
approval: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', true] }],
closed: [{ tuple: [':registrations_open', false] }],
};
return [{
group: ':pleroma',
key: ':instance',
value: configMap[mode],
}];
};
const modeFromInstance = ({ registrations }: Instance): RegistrationMode => {
if (registrations.approval_required && registrations.enabled) return 'approval';
return registrations.enabled ? 'open' : 'closed';
};
/** Allows changing the registration mode of the instance, eg "open", "closed", "approval" */
const RegistrationModePicker: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const instance = useInstance();
const mode = modeFromInstance(instance);
const onChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const config = generateConfig(e.target.value as RegistrationMode);
dispatch(updateConfig(config)).then(() => {
toast.success(intl.formatMessage(messages.saved));
}).catch(() => {});
};
return (
<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>
);
};
export { RegistrationModePicker as default };

View File

@ -0,0 +1,64 @@
import React from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { deleteStatusModal } from 'soapbox/actions/moderation';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusContent from 'soapbox/components/status-content';
import StatusMedia from 'soapbox/components/status-media';
import { HStack, Stack } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import type { SelectedStatus } from 'soapbox/selectors';
const messages = defineMessages({
viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' },
});
interface IReportStatus {
status: SelectedStatus;
}
const ReportStatus: React.FC<IReportStatus> = ({ status }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleDeleteStatus = () => {
dispatch(deleteStatusModal(intl, status.id));
};
const makeMenu = () => {
const acct = status.account.acct;
return [{
text: intl.formatMessage(messages.viewStatus, { acct: `@${acct}` }),
to: `/@${acct}/posts/${status.id}`,
icon: require('@tabler/icons/outline/pencil.svg'),
}, {
text: intl.formatMessage(messages.deleteStatus, { acct: `@${acct}` }),
action: handleDeleteStatus,
icon: require('@tabler/icons/outline/trash.svg'),
destructive: true,
}];
};
const menu = makeMenu();
return (
<HStack space={2} alignItems='start'>
<Stack space={2} className='overflow-hidden' grow>
<StatusContent status={status} />
<StatusMedia status={status} showMedia />
</Stack>
<div className='flex-none'>
<DropdownMenu
items={menu}
src={require('@tabler/icons/outline/dots-vertical.svg')}
/>
</div>
</HStack>
);
};
export { ReportStatus as default };

View File

@ -0,0 +1,159 @@
import React, { useCallback, useState } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import { closeReport } from 'soapbox/actions/admin';
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors';
import toast from 'soapbox/toast';
import ReportStatus from './report-status';
const messages = defineMessages({
reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' },
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
});
interface IReport {
id: string;
}
const Report: React.FC<IReport> = ({ id }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getReport = useCallback(makeGetReport(), []);
const report = useAppSelector((state) => getReport(state, id));
const [accordionExpanded, setAccordionExpanded] = useState(false);
if (!report) return null;
const account = report.account;
const targetAccount = report.target_account!;
const makeMenu = () => [{
text: intl.formatMessage(messages.deactivateUser, { name: targetAccount.username }),
action: handleDeactivateUser,
icon: require('@tabler/icons/outline/hourglass-empty.svg'),
}, {
text: intl.formatMessage(messages.deleteUser, { name: targetAccount.username }),
action: handleDeleteUser,
icon: require('@tabler/icons/outline/trash.svg'),
destructive: true,
}];
const handleCloseReport = () => {
dispatch(closeReport(report.id)).then(() => {
const message = intl.formatMessage(messages.reportClosed, { name: targetAccount.username as string });
toast.success(message);
}).catch(() => {});
};
const handleDeactivateUser = () => {
const accountId = targetAccount.id;
dispatch(deactivateUserModal(intl, accountId!, () => handleCloseReport()));
};
const handleDeleteUser = () => {
const accountId = targetAccount.id as string;
dispatch(deleteUserModal(intl, accountId!, () => handleCloseReport()));
};
const handleAccordionToggle = (setting: boolean) => {
setAccordionExpanded(setting);
};
const menu = makeMenu();
const statuses = report.statuses;
const statusCount = statuses.length;
const acct = targetAccount.acct;
const reporterAcct = account?.acct;
return (
<HStack space={3} className='p-3' key={report.id}>
<HoverRefWrapper accountId={targetAccount.id} inline>
<Link to={`/@${acct}`} title={acct}>
<Avatar
src={targetAccount.avatar}
alt={targetAccount.avatar_description}
size={32}
className='overflow-hidden'
/>
</Link>
</HoverRefWrapper>
<Stack space={3} className='overflow-hidden' grow>
<Text tag='h4' weight='bold'>
<FormattedMessage
id='admin.reports.report_title'
defaultMessage='Report on {acct}'
values={{ acct: (
<HoverRefWrapper accountId={targetAccount.id} inline>
<Link to={`/@${acct}`} title={acct}>@{acct}</Link>
</HoverRefWrapper>
) }}
/>
</Text>
{statusCount > 0 && (
<Accordion
headline={`Reported posts (${statusCount})`}
expanded={accordionExpanded}
onToggle={handleAccordionToggle}
>
<Stack space={4}>
{statuses.map(status => (
<ReportStatus
key={status.id}
status={status}
/>
))}
</Stack>
</Accordion>
)}
<Stack>
{!!report.comment && report.comment.length > 0 && (
<Text
tag='blockquote'
dangerouslySetInnerHTML={{ __html: report.comment }}
/>
)}
{!!account && (
<HStack space={1}>
<Text theme='muted' tag='span'>&mdash;</Text>
<HoverRefWrapper accountId={account.id} inline>
<Link
to={`/@${reporterAcct}`}
title={reporterAcct}
className='text-primary-600 hover:underline dark:text-accent-blue'
>
@{reporterAcct}
</Link>
</HoverRefWrapper>
</HStack>
)}
</Stack>
</Stack>
<HStack space={2} alignItems='start' className='flex-none'>
<Button onClick={handleCloseReport}>
<FormattedMessage id='admin.reports.actions.close' defaultMessage='Close' />
</Button>
<DropdownMenu items={menu} src={require('@tabler/icons/outline/dots-vertical.svg')} />
</HStack>
</HStack>
);
};
export { Report as default };

View File

@ -0,0 +1,47 @@
import React from 'react';
import { approveUser, deleteUser } from 'soapbox/actions/admin';
import { useAccount } from 'soapbox/api/hooks';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
import { Stack, HStack, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
interface IUnapprovedAccount {
accountId: string;
}
/** Displays an unapproved account for moderation purposes. */
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
const dispatch = useAppDispatch();
const { account } = useAccount(accountId);
const adminAccount = useAppSelector(state => state.admin.users.get(accountId));
if (!account) return null;
const handleApprove = () => dispatch(approveUser(account.id));
const handleReject = () => dispatch(deleteUser(account.id));
return (
<HStack space={4} justifyContent='between'>
<Stack space={1}>
<Text weight='semibold'>
@{account.acct}
</Text>
<Text tag='blockquote' size='sm'>
{adminAccount?.invite_request || ''}
</Text>
</Stack>
<Stack justifyContent='center'>
<AuthorizeRejectButtons
onAuthorize={handleApprove}
onReject={handleReject}
countdown={3000}
/>
</Stack>
</HStack>
);
};
export { UnapprovedAccount as default };