Files
mastodon/app/javascript/flavours/glitch/features/collections/editor/details.tsx
2026-03-25 22:07:25 +01:00

426 lines
11 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import { languages } from '@/flavours/glitch/initial_state';
import {
hasSpecialCharacters,
inputToHashtag,
} from '@/flavours/glitch/utils/hashtags';
import type {
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
} from 'flavours/glitch/api_types/collections';
import { Button } from 'flavours/glitch/components/button';
import {
CheckboxField,
ComboboxField,
Fieldset,
FormStack,
RadioButtonField,
SelectField,
TextAreaField,
} from 'flavours/glitch/components/form_fields';
import { TextInputField } from 'flavours/glitch/components/form_fields/text_input_field';
import { useSearchTags } from 'flavours/glitch/hooks/useSearchTags';
import type { TagSearchResult } from 'flavours/glitch/hooks/useSearchTags';
import {
createCollection,
updateCollection,
updateCollectionEditorField,
} from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionDetails: React.FC = () => {
const dispatch = useAppDispatch();
const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, accountIds } =
useAppSelector((state) => state.collections.editor);
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(
updateCollectionEditorField({
field: 'name',
value: event.target.value,
}),
);
},
[dispatch],
);
const handleDescriptionChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
dispatch(
updateCollectionEditorField({
field: 'description',
value: event.target.value,
}),
);
},
[dispatch],
);
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(
updateCollectionEditorField({
field: 'discoverable',
value: event.target.value === 'public',
}),
);
},
[dispatch],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(
updateCollectionEditorField({
field: 'sensitive',
value: event.target.checked,
}),
);
},
[dispatch],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (id) {
const payload: ApiUpdateCollectionPayload = {
id,
name,
description,
tag_name: topic || null,
discoverable,
sensitive,
};
void dispatch(updateCollection({ payload })).then(() => {
history.goBack();
});
} else {
const payload: ApiCreateCollectionPayload = {
name,
description,
discoverable,
sensitive,
account_ids: accountIds,
};
if (topic) {
payload.tag_name = topic;
}
void dispatch(
createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(`/collections`);
history.push(`/collections/${result.payload.collection.id}`, {
newCollection: true,
});
}
});
}
},
[
id,
name,
description,
topic,
discoverable,
sensitive,
dispatch,
history,
accountIds,
],
);
return (
<form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}>
{!id && (
<WizardStepHeader
step={2}
title={
<FormattedMessage
id='collections.create.basic_details_title'
defaultMessage='Basic details'
/>
}
/>
)}
<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}
/>
<TopicField />
<LanguageField />
<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>
</form>
);
};
const TopicField: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { topic } = useAppSelector((state) => state.collections.editor);
const { tags, isLoading, searchTags } = useSearchTags({
query: topic,
});
const handleTopicChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(
updateCollectionEditorField({
field: 'topic',
value: inputToHashtag(event.target.value),
}),
);
searchTags(event.target.value);
},
[dispatch, searchTags],
);
const handleSelectTopicSuggestion = useCallback(
(item: TagSearchResult) => {
dispatch(
updateCollectionEditorField({
field: 'topic',
value: inputToHashtag(item.name),
}),
);
},
[dispatch],
);
const topicHasSpecialCharacters = useMemo(
() => hasSpecialCharacters(topic),
[topic],
);
return (
<ComboboxField
required={false}
icon={null}
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}
items={tags}
isLoading={isLoading}
renderItem={renderTagItem}
onSelectItem={handleSelectTopicSuggestion}
onChange={handleTopicChange}
autoCapitalize='off'
autoCorrect='off'
spellCheck='false'
maxLength={40}
status={
topicHasSpecialCharacters
? {
variant: 'warning',
message: intl.formatMessage({
id: 'collections.topic_special_chars_hint',
defaultMessage:
'Special characters will be removed when saving',
}),
}
: undefined
}
suppressMenu={!tags.length}
/>
);
};
const renderTagItem = (item: TagSearchResult) => item.label ?? `#${item.name}`;
const LanguageField: React.FC = () => {
const dispatch = useAppDispatch();
const initialLanguage = useAppSelector(
(state) => state.compose.get('default_language') as string,
);
const { language } = useAppSelector((state) => state.collections.editor);
const selectedLanguage = language ?? initialLanguage;
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
dispatch(
updateCollectionEditorField({
field: 'language',
value: event.target.value,
}),
);
},
[dispatch],
);
return (
<SelectField
label={
<FormattedMessage
id='collections.collection_language'
defaultMessage='Language'
/>
}
value={selectedLanguage}
onChange={handleLanguageChange}
>
<option value=''>
<FormattedMessage
id='collections.collection_language_none'
defaultMessage='None'
/>
</option>
{languages?.map(([code, name, localName]) => (
<option key={code} value={code}>
{localName} ({name})
</option>
))}
</SelectField>
);
};