[Glitch] Merge collection settings into single editor page

Port c118ff708f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2026-02-19 15:09:52 +01:00
committed by Claire
parent 3ac5b10dec
commit 79fba522e1
8 changed files with 254 additions and 307 deletions

View File

@@ -13,8 +13,9 @@ import { CollectionMenu } from './collection_menu';
export const CollectionMetaData: React.FC<{ export const CollectionMetaData: React.FC<{
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
extended?: boolean;
className?: string; className?: string;
}> = ({ collection, className }) => { }> = ({ collection, extended, className }) => {
return ( return (
<ul className={classNames(classes.metaList, className)}> <ul className={classNames(classes.metaList, className)}>
<FormattedMessage <FormattedMessage
@@ -23,6 +24,30 @@ export const CollectionMetaData: React.FC<{
values={{ count: collection.item_count }} values={{ count: collection.item_count }}
tagName='li' tagName='li'
/> />
{extended && (
<>
{collection.discoverable ? (
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
tagName='li'
/>
) : (
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
tagName='li'
/>
)}
{collection.sensitive && (
<FormattedMessage
id='collections.sensitive'
defaultMessage='Sensitive'
tagName='li'
/>
)}
</>
)}
<FormattedMessage <FormattedMessage
id='collections.last_updated_at' id='collections.last_updated_at'
defaultMessage='Last updated: {date}' defaultMessage='Last updated: {date}'

View File

@@ -55,10 +55,6 @@ export const CollectionMenu: React.FC<{
text: intl.formatMessage(editorMessages.editDetails), text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`, to: `/collections/${id}/edit/details`,
}, },
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null, null,
{ {
text: intl.formatMessage(messages.delete), text: intl.formatMessage(messages.delete),

View File

@@ -114,6 +114,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
{description && <p className={classes.description}>{description}</p>} {description && <p className={classes.description}>{description}</p>}
<AuthorNote id={collection.account_id} /> <AuthorNote id={collection.account_id} />
<CollectionMetaData <CollectionMetaData
extended
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />

View File

@@ -4,6 +4,8 @@ import { FormattedMessage } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import type { import type {
ApiCollectionJSON, ApiCollectionJSON,
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
@@ -11,11 +13,17 @@ import type {
} from 'flavours/glitch/api_types/collections'; } from 'flavours/glitch/api_types/collections';
import { Button } from 'flavours/glitch/components/button'; import { Button } from 'flavours/glitch/components/button';
import { import {
CheckboxField,
Fieldset,
FormStack, FormStack,
RadioButtonField,
TextAreaField, TextAreaField,
} from 'flavours/glitch/components/form_fields'; } from 'flavours/glitch/components/form_fields';
import { TextInputField } from 'flavours/glitch/components/form_fields/text_input_field'; import { TextInputField } from 'flavours/glitch/components/form_fields/text_input_field';
import { updateCollection } from 'flavours/glitch/reducers/slices/collections'; import {
createCollection,
updateCollection,
} from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch } from 'flavours/glitch/store'; import { useAppDispatch } from 'flavours/glitch/store';
import type { TempCollectionState } from './state'; import type { TempCollectionState } from './state';
@@ -30,12 +38,21 @@ export const CollectionDetails: React.FC<{
const history = useHistory(); const history = useHistory();
const location = useLocation<TempCollectionState>(); const location = useLocation<TempCollectionState>();
const { id, initialName, initialDescription, initialTopic, initialItemIds } = const {
getCollectionEditorState(collection, location.state); id,
initialName,
initialDescription,
initialTopic,
initialItemIds,
initialDiscoverable,
initialSensitive,
} = getCollectionEditorState(collection, location.state);
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription); const [description, setDescription] = useState(initialDescription);
const [topic, setTopic] = useState(initialTopic); const [topic, setTopic] = useState(initialTopic);
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleNameChange = useCallback( const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => { (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -58,6 +75,20 @@ export const CollectionDetails: React.FC<{
[], [],
); );
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(event.target.value === 'public');
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -68,108 +99,208 @@ export const CollectionDetails: React.FC<{
name, name,
description, description,
tag_name: topic || null, tag_name: topic || null,
discoverable,
sensitive,
}; };
void dispatch(updateCollection({ payload })).then(() => { void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections/${id}`); history.goBack();
}); });
} else { } else {
const payload: Partial<ApiCreateCollectionPayload> = { const payload: ApiCreateCollectionPayload = {
name, name,
description, description,
tag_name: topic || null, discoverable,
sensitive,
account_ids: initialItemIds, account_ids: initialItemIds,
}; };
if (topic) {
payload.tag_name = topic;
}
history.replace('/collections/new', payload); void dispatch(
history.push('/collections/new/settings', payload); createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit/details`,
);
history.push(`/collections/${result.payload.collection.id}`);
}
});
} }
}, },
[id, name, description, topic, dispatch, history, initialItemIds], [
id,
name,
description,
topic,
discoverable,
sensitive,
dispatch,
history,
initialItemIds,
],
); );
return ( return (
<FormStack as='form' onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className={classes.form}>
{!id && ( <FormStack className={classes.formFieldStack}>
<WizardStepHeader {!id && (
step={2} <WizardStepHeader
title={ step={2}
title={
<FormattedMessage
id='collections.create.basic_details_title'
defaultMessage='Basic details'
/>
}
/>
)}
<TextInputField
required
label={
<FormattedMessage <FormattedMessage
id='collections.create.basic_details_title' id='collections.collection_name'
defaultMessage='Basic details' defaultMessage='Name'
/> />
} }
/> hint={
)}
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage <FormattedMessage
id='collections.continue' id='collections.name_length_hint'
defaultMessage='Continue' defaultMessage='40 characters limit'
/> />
)} }
</Button> value={name}
onChange={handleNameChange}
maxLength={40}
/>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
<Fieldset
legend={
<FormattedMessage
id='collections.visibility_title'
defaultMessage='Visibility'
/>
}
>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
/>
}
hint={
<FormattedMessage
id='collections.visibility_public_hint'
defaultMessage='Discoverable in search results and other areas where recommendations appear.'
/>
}
value='public'
checked={discoverable}
onChange={handleDiscoverableChange}
/>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
/>
}
hint={
<FormattedMessage
id='collections.visibility_unlisted_hint'
defaultMessage='Visible to anyone with a link. Hidden from search results and recommendations.'
/>
}
value='unlisted'
checked={!discoverable}
onChange={handleDiscoverableChange}
/>
</Fieldset>
<Fieldset
legend={
<FormattedMessage
id='collections.content_warning'
defaultMessage='Content warning'
/>
}
>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</Fieldset>
</FormStack>
<div className={classes.stickyFooter}>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
</div> </div>
</FormStack> </form>
); );
}; };

View File

@@ -21,7 +21,6 @@ import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { CollectionAccounts } from './accounts'; import { CollectionAccounts } from './accounts';
import { CollectionDetails } from './details'; import { CollectionDetails } from './details';
import { CollectionSettings } from './settings';
export const messages = defineMessages({ export const messages = defineMessages({
create: { create: {
@@ -34,20 +33,12 @@ export const messages = defineMessages({
}, },
editDetails: { editDetails: {
id: 'collections.edit_details', id: 'collections.edit_details',
defaultMessage: 'Edit basic details', defaultMessage: 'Edit details',
}, },
manageAccounts: { manageAccounts: {
id: 'collections.manage_accounts', id: 'collections.manage_accounts',
defaultMessage: 'Manage accounts', defaultMessage: 'Manage accounts',
}, },
manageAccountsLong: {
id: 'collections.manage_accounts_in_collection',
defaultMessage: 'Manage accounts in this collection',
},
editSettings: {
id: 'collections.edit_settings',
defaultMessage: 'Edit settings',
},
}); });
function usePageTitle(id: string | undefined) { function usePageTitle(id: string | undefined) {
@@ -62,8 +53,6 @@ function usePageTitle(id: string | undefined) {
return messages.manageAccounts; return messages.manageAccounts;
} else if (matchPath(location.pathname, { path: `${path}/details` })) { } else if (matchPath(location.pathname, { path: `${path}/details` })) {
return messages.editDetails; return messages.editDetails;
} else if (matchPath(location.pathname, { path: `${path}/settings` })) {
return messages.editSettings;
} else { } else {
throw new Error('No page title defined for route'); throw new Error('No page title defined for route');
} }
@@ -117,11 +106,6 @@ export const CollectionEditorPage: React.FC<{
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
render={() => <CollectionDetails collection={collection} />} render={() => <CollectionDetails collection={collection} />}
/> />
<Route
path={`${path}/settings`}
// eslint-disable-next-line react/jsx-no-bind
render={() => <CollectionSettings collection={collection} />}
/>
</Switch> </Switch>
)} )}
</div> </div>

View File

@@ -1,199 +0,0 @@
import { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import type {
ApiCollectionJSON,
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
} from 'flavours/glitch/api_types/collections';
import { Button } from 'flavours/glitch/components/button';
import {
Fieldset,
FormStack,
CheckboxField,
RadioButtonField,
} from 'flavours/glitch/components/form_fields';
import {
createCollection,
updateCollection,
} from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch } from 'flavours/glitch/store';
import type { TempCollectionState } from './state';
import { getCollectionEditorState } from './state';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionSettings: React.FC<{
collection?: ApiCollectionJSON | null;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const location = useLocation<TempCollectionState>();
const { id, initialDiscoverable, initialSensitive, ...editorState } =
getCollectionEditorState(collection, location.state);
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(event.target.value === 'public');
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (id) {
const payload: ApiUpdateCollectionPayload = {
id,
discoverable,
sensitive,
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections/${id}`);
});
} else {
const payload: ApiCreateCollectionPayload = {
name: editorState.initialName,
description: editorState.initialDescription,
discoverable,
sensitive,
account_ids: editorState.initialItemIds,
};
if (editorState.initialTopic) {
payload.tag_name = editorState.initialTopic;
}
void dispatch(
createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit/settings`,
);
history.push(`/collections`);
}
});
}
},
[id, discoverable, sensitive, dispatch, history, editorState],
);
return (
<FormStack as='form' onSubmit={handleSubmit}>
{!id && (
<WizardStepHeader
step={3}
title={
<FormattedMessage
id='collections.create.settings_title'
defaultMessage='Settings'
/>
}
/>
)}
<Fieldset
legend={
<FormattedMessage
id='collections.visibility_title'
defaultMessage='Visibility'
/>
}
>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
/>
}
hint={
<FormattedMessage
id='collections.visibility_public_hint'
defaultMessage='Discoverable in search results and other areas where recommendations appear.'
/>
}
value='public'
checked={discoverable}
onChange={handleDiscoverableChange}
/>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
/>
}
hint={
<FormattedMessage
id='collections.visibility_unlisted_hint'
defaultMessage='Visible to anyone with a link. Hidden from search results and recommendations.'
/>
}
value='unlisted'
checked={!discoverable}
onChange={handleDiscoverableChange}
/>
</Fieldset>
<Fieldset
legend={
<FormattedMessage
id='collections.content_warning'
defaultMessage='Content warning'
/>
}
>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</Fieldset>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
</FormStack>
);
};

View File

@@ -12,7 +12,7 @@ export const WizardStepHeader: React.FC<{
<FormattedMessage <FormattedMessage
id='collections.create.steps' id='collections.create.steps'
defaultMessage='Step {step}/{total}' defaultMessage='Step {step}/{total}'
values={{ step, total: 3 }} values={{ step, total: 2 }}
> >
{(content) => <p className={classes.step}>{content}</p>} {(content) => <p className={classes.step}>{content}</p>}
</FormattedMessage> </FormattedMessage>

View File

@@ -15,6 +15,7 @@ import type {
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
ApiUpdateCollectionPayload, ApiUpdateCollectionPayload,
} from '@/flavours/glitch/api_types/collections'; } from '@/flavours/glitch/api_types/collections';
import { me } from '@/flavours/glitch/initial_state';
import { import {
createAppSelector, createAppSelector,
createDataLoadingThunk, createDataLoadingThunk,
@@ -111,6 +112,14 @@ const collectionSlice = createSlice({
const { collectionId } = action.meta.arg; const { collectionId } = action.meta.arg;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete state.collections[collectionId]; delete state.collections[collectionId];
if (me) {
let accountCollectionIds = state.accountCollections[me]?.collectionIds;
if (accountCollectionIds) {
accountCollectionIds = accountCollectionIds.filter(
(id) => id !== collectionId,
);
}
}
}); });
/** /**