Merge branch 'feat-admin-ui-update-ditto-instance' into 'main'

Feat admin ui update ditto instance

Closes #1781

See merge request soapbox-pub/soapbox!3258
merge-requests/3281/head
Alex Gleason 2024-11-26 01:20:57 +00:00
commit af85629ef0
8 zmienionych plików z 409 dodań i 2 usunięć

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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;

Wyświetl plik

@ -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'

Wyświetl plik

@ -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 />}

Wyświetl plik

@ -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'));

Wyświetl plik

@ -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",

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };