kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'feat-admin-ui-update-ditto-instance' into 'main'
Feat admin ui update ditto instance Closes #1781 See merge request soapbox-pub/soapbox!3258merge-requests/3281/head
commit
af85629ef0
|
@ -0,0 +1,22 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { DittoInstanceCredentials } from 'soapbox/features/admin/manage-ditto-server.tsx';
|
||||
import { useApi } from 'soapbox/hooks/useApi.ts';
|
||||
import { queryClient } from 'soapbox/queries/client.ts';
|
||||
|
||||
function useManageDittoServer() {
|
||||
const api = useApi();
|
||||
|
||||
const { mutate: updateDittoInstance } = useMutation({
|
||||
mutationFn: (data: DittoInstanceCredentials) => api.put('/api/v1/admin/ditto/instance', data),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ['instance', api.baseUrl, 'v2'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateDittoInstance,
|
||||
};
|
||||
}
|
||||
|
||||
export { useManageDittoServer };
|
|
@ -0,0 +1,327 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { uploadMedia } from 'soapbox/actions/media.ts';
|
||||
import { HTTPError } from 'soapbox/api/HTTPError.ts';
|
||||
import { useInstanceV2 } from 'soapbox/api/hooks/instance/useInstanceV2.ts';
|
||||
import StillImage from 'soapbox/components/still-image.tsx';
|
||||
import { Button } from 'soapbox/components/ui/button.tsx';
|
||||
import { Column } from 'soapbox/components/ui/column.tsx';
|
||||
import FileInput from 'soapbox/components/ui/file-input.tsx';
|
||||
import FormActions from 'soapbox/components/ui/form-actions.tsx';
|
||||
import FormGroup from 'soapbox/components/ui/form-group.tsx';
|
||||
import Form from 'soapbox/components/ui/form.tsx';
|
||||
import Input from 'soapbox/components/ui/input.tsx';
|
||||
import Spinner from 'soapbox/components/ui/spinner.tsx';
|
||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||
import Streamfield from 'soapbox/components/ui/streamfield.tsx';
|
||||
import { useManageDittoServer } from 'soapbox/features/admin/hooks/useManageDittoServer.ts';
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers/index.ts';
|
||||
import { thumbnailSchema } from 'soapbox/schemas/instance.ts';
|
||||
import { Screenshots } from 'soapbox/schemas/manifest.ts';
|
||||
import toast from 'soapbox/toast.tsx';
|
||||
|
||||
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield.tsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.ditto_server.manage', defaultMessage: 'Manage Ditto Server' },
|
||||
title: { id: 'column.admin.ditto_server.title', defaultMessage: 'Title' },
|
||||
description: { id: 'column.admin.ditto_server.description', defaultMessage: 'Description' },
|
||||
short_description: { id: 'column.admin.ditto_server.short_description', defaultMessage: 'Short Description' },
|
||||
thumbnail: { id: 'column.admin.ditto_server.thumbnail', defaultMessage: 'Thumbnail' },
|
||||
screenshots_label: { id: 'column.admin.ditto_server.screenshots.label', defaultMessage: 'Web Manifest screenshots' },
|
||||
screeenshot_label: { id: 'column.admin.ditto_server.screenshot.label', defaultMessage: 'Alternative text describing the image.' },
|
||||
upload_screenshot_success: { id: 'column.admin.ditto_server.upload.screenshot.success', defaultMessage: 'Screenshot uploaded!' },
|
||||
upload_thumbnail_success: { id: 'column.admin.ditto_server.upload.thumbnail.success', defaultMessage: 'Thumbnail uploaded!' },
|
||||
submit_success: { id: 'column.admin.ditto_server.submit.success', defaultMessage: 'Submitted successfully!' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Params to submit when updating a Ditto instance.
|
||||
* @see PUT /api/v1/admin/ditto/instance
|
||||
*/
|
||||
export interface DittoInstanceCredentials {
|
||||
/** Title of the instance. */
|
||||
title: string;
|
||||
/** Description of the instance. */
|
||||
description: string;
|
||||
/** Short description of the instance. */
|
||||
short_description: string;
|
||||
/** Manifest screenshots. */
|
||||
screenshots: Screenshots;
|
||||
/** https://docs.joinmastodon.org/entities/Instance/#thumbnail-url */
|
||||
thumbnail: Zod.infer<typeof thumbnailSchema>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main component that handles the logic and UI for managing a Ditto instance.
|
||||
* Allows the admin to view and edit title, description, screenshots (Manifest field), etc...
|
||||
*
|
||||
* @returns A component that renders the Ditto Server management interface.
|
||||
*/
|
||||
const ManageDittoServer: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { updateDittoInstance } = useManageDittoServer();
|
||||
const { instance } = useInstanceV2();
|
||||
|
||||
const [data, setData] = useState<DittoInstanceCredentials>({
|
||||
title: instance?.title ?? '',
|
||||
description: instance?.description ?? '',
|
||||
short_description: instance?.short_description ?? '',
|
||||
screenshots: instance?.screenshots ?? [],
|
||||
thumbnail: instance?.thumbnail ?? { url: '', versions: {} },
|
||||
});
|
||||
|
||||
const [isThumbnailLoading, setThumbnailLoading] = useState<boolean>(false);
|
||||
|
||||
const handleSubmit: React.FormEventHandler = async (event) => {
|
||||
event.preventDefault();
|
||||
updateDittoInstance(data, {
|
||||
onSuccess: async () => {
|
||||
toast.success(messages.submit_success);
|
||||
},
|
||||
onError: async (err) => {
|
||||
if (err instanceof HTTPError) {
|
||||
try {
|
||||
const { error } = await err.response.json();
|
||||
if (typeof error === 'string') {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
} catch { /* empty */ }
|
||||
}
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Set a single key in the request data. */
|
||||
const updateData = (key: string, value: any) => {
|
||||
setData(prevData => {
|
||||
return { ...prevData, [key]: value };
|
||||
});
|
||||
};
|
||||
|
||||
const handleTextChange = (key: keyof DittoInstanceCredentials): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => {
|
||||
return e => {
|
||||
updateData(key, e.target.value);
|
||||
};
|
||||
};
|
||||
|
||||
const handleStreamItemChange = (key: Extract<keyof DittoInstanceCredentials, 'screenshots'>) => {
|
||||
return (values: any[]) => {
|
||||
updateData(key, values);
|
||||
};
|
||||
};
|
||||
|
||||
const deleteStreamItem = (key: Extract<keyof DittoInstanceCredentials, 'screenshots'>) => {
|
||||
return (i: number) => {
|
||||
setData(prevData => {
|
||||
const newData = { ...prevData }[key].toSpliced(i, 1);
|
||||
|
||||
return { ...prevData, [key]: newData };
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const handleAddScreenshot = (): void => {
|
||||
setData(prevData => {
|
||||
const newData = { ...prevData };
|
||||
newData.screenshots.push({
|
||||
src: '',
|
||||
});
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleThumbnailChange = (key: Extract<keyof DittoInstanceCredentials, 'thumbnail'>): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
return async(e) => {
|
||||
setThumbnailLoading(true);
|
||||
|
||||
const file = e.target.files ? e.target.files[0] : null;
|
||||
if (!file) return;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await dispatch(uploadMedia(data));
|
||||
const attachment = normalizeAttachment(response.data);
|
||||
|
||||
if (attachment.type !== 'image') {
|
||||
throw new Error('Only images supported.');
|
||||
}
|
||||
|
||||
setData(prevData => {
|
||||
return { ...prevData, [key]: { url: attachment.url, versions: { '@1x': attachment.url, '@2x': attachment.url } } };
|
||||
});
|
||||
|
||||
toast.success(messages.upload_thumbnail_success);
|
||||
setThumbnailLoading(false);
|
||||
} catch (err) {
|
||||
setThumbnailLoading(false);
|
||||
e.target.value = '';
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
toast.error(err.response?.data?.error || 'An error occurred');
|
||||
return;
|
||||
}
|
||||
toast.error((err as Error)?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
|
||||
<FormGroup labelText={intl.formatMessage(messages.title)}>
|
||||
<Input
|
||||
type='text'
|
||||
value={data.title}
|
||||
onChange={handleTextChange('title')}
|
||||
placeholder={intl.formatMessage(messages.title)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={intl.formatMessage(messages.description)}>
|
||||
<Input
|
||||
type='text'
|
||||
value={data.description}
|
||||
onChange={handleTextChange('description')}
|
||||
placeholder={intl.formatMessage(messages.description)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
<FormGroup labelText={intl.formatMessage(messages.short_description)}>
|
||||
<Input
|
||||
type='text'
|
||||
value={data.short_description}
|
||||
onChange={handleTextChange('short_description')}
|
||||
placeholder={intl.formatMessage(messages.short_description)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={intl.formatMessage(messages.thumbnail)}>
|
||||
<Stack space={3} grow className='my-2'>
|
||||
{!isThumbnailLoading && data.thumbnail.url && <StillImage src={data.thumbnail.url} className='size-5/12' />}
|
||||
|
||||
{isThumbnailLoading && <Spinner size={40} withText />}
|
||||
<FileInput
|
||||
onChange={handleThumbnailChange('thumbnail')}
|
||||
accept='image/png,image/jpeg,image/svg+xml,image/webp'
|
||||
/>
|
||||
</Stack>
|
||||
</FormGroup>
|
||||
|
||||
<Streamfield
|
||||
label={intl.formatMessage(messages.screenshots_label)}
|
||||
component={ScreenshotInput}
|
||||
values={data.screenshots || []}
|
||||
onChange={handleStreamItemChange('screenshots')}
|
||||
onAddItem={handleAddScreenshot}
|
||||
onRemoveItem={deleteStreamItem('screenshots')}
|
||||
/>
|
||||
|
||||
<FormActions>
|
||||
<Button to='/admin' theme='tertiary'>
|
||||
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
|
||||
</Button>
|
||||
|
||||
<Button theme='primary' type='submit'>
|
||||
<FormattedMessage id='save' defaultMessage='Save' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
type Screenshot = Screenshots[number]
|
||||
|
||||
const ScreenshotInput: StreamfieldComponent<Screenshot> = ({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isLoading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleChange = (key: keyof Screenshot): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
return e => {
|
||||
onChange({ ...value, [key]: e.currentTarget.value });
|
||||
};
|
||||
};
|
||||
|
||||
const handleFileChange = (key: Extract<keyof Screenshot, 'src'>): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
return async(e) => {
|
||||
setLoading(true);
|
||||
|
||||
const file = e.target.files ? e.target.files[0] : null;
|
||||
if (!file) return;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await dispatch(uploadMedia(data));
|
||||
const attachment = normalizeAttachment(response.data);
|
||||
|
||||
if (attachment.type !== 'image') {
|
||||
throw new Error('Only images supported.');
|
||||
}
|
||||
|
||||
const width = attachment?.meta?.getIn(['original', 'width']);
|
||||
const height = attachment?.meta?.getIn(['original', 'height']);
|
||||
|
||||
if (typeof width === 'number' && typeof height === 'number') {
|
||||
onChange({ ...value, [key]: attachment.get('url'), ['sizes']: `${width}x${height}`, 'label': value.label });
|
||||
} else {
|
||||
onChange({ ...value, [key]: attachment.get('url'), 'label': value.label });
|
||||
}
|
||||
|
||||
toast.success(messages.upload_screenshot_success);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
e.target.value = '';
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
toast.error(err.response?.data?.error || 'An error occurred');
|
||||
return;
|
||||
}
|
||||
toast.error((err as Error)?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space={3} grow className='my-2'>
|
||||
|
||||
{!isLoading && value.src && <StillImage src={value.src} alt={value.label} className='size-5/12' />}
|
||||
|
||||
{isLoading && <Spinner size={40} withText />}
|
||||
|
||||
<FileInput
|
||||
onChange={handleFileChange('src')}
|
||||
accept='image/png,image/jpeg,image/svg+xml,image/webp'
|
||||
/>
|
||||
|
||||
<FormGroup labelText={intl.formatMessage(messages.screeenshot_label)}>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='grow'
|
||||
value={value.label}
|
||||
onChange={handleChange('label')}
|
||||
placeholder={intl.formatMessage(messages.screeenshot_label)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageDittoServer;
|
|
@ -88,6 +88,13 @@ const Dashboard: React.FC = () => {
|
|||
</DashCounters>
|
||||
|
||||
<List>
|
||||
{account.admin && features.nostr && (
|
||||
<ListItem
|
||||
to='/soapbox/admin/ditto-server'
|
||||
label={<FormattedMessage id='column.admin.ditto_server.manage' defaultMessage='Manage Ditto Server' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{account.admin && (
|
||||
<ListItem
|
||||
to='/soapbox/config'
|
||||
|
|
|
@ -151,6 +151,7 @@ import {
|
|||
Rules,
|
||||
AdminNostrRelays,
|
||||
NostrBunkerLogin,
|
||||
ManageDittoServer,
|
||||
} from './util/async-components.ts';
|
||||
import GlobalHotkeys from './util/global-hotkeys.tsx';
|
||||
import { WrappedRoute } from './util/react-router-helpers.tsx';
|
||||
|
@ -337,6 +338,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
|
||||
<WrappedRoute path='/soapbox/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/soapbox/admin/approval' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
{features.nostr && <WrappedRoute path='/soapbox/admin/ditto-server' adminOnly page={WidePage} component={ManageDittoServer} content={children} exact />}
|
||||
<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 />
|
||||
{features.nostr && <WrappedRoute path='/soapbox/admin/zap-split' staffOnly page={WidePage} component={ManageZapSplit} content={children} exact />}
|
||||
|
|
|
@ -173,6 +173,7 @@ export const EditDomainModal = lazy(() => import('soapbox/features/ui/components
|
|||
export const NostrRelays = lazy(() => import('soapbox/features/nostr-relays/index.tsx'));
|
||||
export const Bech32Redirect = lazy(() => import('soapbox/features/nostr/Bech32Redirect.tsx'));
|
||||
export const ManageZapSplit = lazy(() => import('soapbox/features/admin/manage-zap-split.tsx'));
|
||||
export const ManageDittoServer = lazy(() => import('soapbox/features/admin/manage-ditto-server.tsx'));
|
||||
export const Relays = lazy(() => import('soapbox/features/admin/relays.tsx'));
|
||||
export const Rules = lazy(() => import('soapbox/features/admin/rules.tsx'));
|
||||
export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/modals/edit-rule-modal.tsx'));
|
||||
|
|
|
@ -333,6 +333,16 @@
|
|||
"column.admin.create_domain": "Create domaian",
|
||||
"column.admin.create_rule": "Create rule",
|
||||
"column.admin.dashboard": "Dashboard",
|
||||
"column.admin.ditto_server.description": "Description",
|
||||
"column.admin.ditto_server.manage": "Manage Ditto Server",
|
||||
"column.admin.ditto_server.screenshot.label": "Alternative text describing the image.",
|
||||
"column.admin.ditto_server.screenshots.label": "Web Manifest screenshots",
|
||||
"column.admin.ditto_server.short_description": "Short Description",
|
||||
"column.admin.ditto_server.submit.success": "Submitted successfully!",
|
||||
"column.admin.ditto_server.thumbnail": "Thumbnail",
|
||||
"column.admin.ditto_server.title": "Title",
|
||||
"column.admin.ditto_server.upload.screenshot.success": "Screenshot uploaded!",
|
||||
"column.admin.ditto_server.upload.thumbnail.success": "Thumbnail uploaded!",
|
||||
"column.admin.domains": "Domains",
|
||||
"column.admin.edit_announcement": "Edit announcement",
|
||||
"column.admin.edit_domain": "Edit domain",
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import z from 'zod';
|
||||
|
||||
import { accountSchema } from './account.ts';
|
||||
import { screenshotsSchema } from './manifest.ts';
|
||||
import { mrfSimpleSchema } from './pleroma.ts';
|
||||
import { ruleSchema } from './rule.ts';
|
||||
import { coerceObject, filteredArray, mimeSchema } from './utils.ts';
|
||||
|
@ -212,6 +213,8 @@ const instanceV2Schema = coerceObject({
|
|||
pleroma: pleromaSchema,
|
||||
registrations: registrationsSchema,
|
||||
rules: filteredArray(ruleSchema),
|
||||
screenshots: screenshotsSchema.catch([]),
|
||||
short_description: z.string().catch(''),
|
||||
source_url: z.string().url().optional().catch(undefined),
|
||||
thumbnail: thumbnailSchema,
|
||||
title: z.string().catch(''),
|
||||
|
@ -227,7 +230,7 @@ function upgradeInstance(v1: InstanceV1): InstanceV2 {
|
|||
account: v1.contact_account,
|
||||
email: v1.email,
|
||||
},
|
||||
description: v1.short_description,
|
||||
description: v1.short_description, // Shouldn't it be "v1.description" ?
|
||||
domain: v1.uri,
|
||||
icon: [],
|
||||
languages: v1.languages,
|
||||
|
@ -238,6 +241,8 @@ function upgradeInstance(v1: InstanceV1): InstanceV2 {
|
|||
enabled: v1.registrations,
|
||||
},
|
||||
rules: v1.rules,
|
||||
screenshots: [],
|
||||
short_description: v1.short_description,
|
||||
thumbnail: {
|
||||
url: v1.thumbnail,
|
||||
versions: {
|
||||
|
@ -255,4 +260,4 @@ function upgradeInstance(v1: InstanceV1): InstanceV2 {
|
|||
type InstanceV1 = z.infer<typeof instanceV1Schema>;
|
||||
type InstanceV2 = z.infer<typeof instanceV2Schema>;
|
||||
|
||||
export { instanceV1Schema, type InstanceV1, instanceV2Schema, type InstanceV2, upgradeInstance };
|
||||
export { instanceV1Schema, type InstanceV1, instanceV2Schema, type InstanceV2, upgradeInstance, thumbnailSchema };
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import z from 'zod';
|
||||
|
||||
const screenshotsSchema = z.array(z.object({
|
||||
form_factor: z.enum(['narrow', 'wide']).optional(),
|
||||
label: z.string().optional(),
|
||||
platform: z.enum([
|
||||
'android',
|
||||
'chromeos',
|
||||
'ipados',
|
||||
'ios',
|
||||
'kaios',
|
||||
'macos',
|
||||
'windows',
|
||||
'xbox',
|
||||
'chrome_web_store',
|
||||
'itunes',
|
||||
'microsoft-inbox',
|
||||
'microsoft-store',
|
||||
'play',
|
||||
]).optional(),
|
||||
/** https://developer.mozilla.org/en-US/docs/Web/Manifest/screenshots#sizes */
|
||||
sizes: z.string().refine((value) =>
|
||||
value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v)),
|
||||
).optional(),
|
||||
/** Absolute URL. */
|
||||
src: z.string().url(),
|
||||
/** MIME type of the image. */
|
||||
type: z.string().optional(),
|
||||
}));
|
||||
|
||||
type Screenshots = z.infer<typeof screenshotsSchema>;
|
||||
|
||||
export { screenshotsSchema, type Screenshots };
|
Ładowanie…
Reference in New Issue