From 2427e14446cf340c77daa791b32217c4e4e28c85 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 29 Jan 2026 10:06:49 +0100 Subject: [PATCH] Add initial collections editor page (#37643) --- .../mastodon/api_types/collections.ts | 2 +- .../mastodon/components/form_fields/index.ts | 1 + .../mastodon/features/collections/editor.tsx | 266 ++++++++++++++++++ .../mastodon/features/collections/index.tsx | 22 +- app/javascript/mastodon/features/ui/index.jsx | 7 + .../features/ui/util/async-components.js | 6 + app/javascript/mastodon/locales/en.json | 10 + 7 files changed, 292 insertions(+), 22 deletions(-) create mode 100644 app/javascript/mastodon/features/collections/editor.tsx diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index 954abfae5e..61ed9d9439 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -70,7 +70,7 @@ type CommonPayloadFields = Pick< ApiCollectionJSON, 'name' | 'description' | 'sensitive' | 'discoverable' > & { - tag?: string; + tag_name?: string; }; export interface ApiPatchCollectionPayload extends Partial { diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts index 2aa8764514..8100d56049 100644 --- a/app/javascript/mastodon/components/form_fields/index.ts +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -1,3 +1,4 @@ export { TextInputField } from './text_input_field'; export { TextAreaField } from './text_area_field'; +export { ToggleField, PlainToggleField } from './toggle_field'; export { SelectField } from './select_field'; diff --git a/app/javascript/mastodon/features/collections/editor.tsx b/app/javascript/mastodon/features/collections/editor.tsx new file mode 100644 index 0000000000..29659edf9e --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor.tsx @@ -0,0 +1,266 @@ +import { useCallback, useState, useEffect } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { useParams, useHistory } from 'react-router-dom'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import type { + ApiCollectionJSON, + ApiCreateCollectionPayload, +} from 'mastodon/api_types/collections'; +import { Button } from 'mastodon/components/button'; +import { Column } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { TextAreaField, ToggleField } from 'mastodon/components/form_fields'; +import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { createCollection } from 'mastodon/reducers/slices/collections'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + edit: { id: 'column.edit_collection', defaultMessage: 'Edit collection' }, + create: { + id: 'column.create_collection', + defaultMessage: 'Create collection', + }, +}); + +const CollectionSettings: React.FC<{ + collection?: ApiCollectionJSON | null; +}> = ({ collection }) => { + const dispatch = useAppDispatch(); + const history = useHistory(); + + const { + id, + name: initialName = '', + description: initialDescription = '', + tag, + discoverable: initialDiscoverable = true, + sensitive: initialSensitive = false, + } = collection ?? {}; + + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription); + const [topic, setTopic] = useState(tag?.name ?? ''); + const [discoverable] = useState(initialDiscoverable); + const [sensitive, setSensitive] = useState(initialSensitive); + + const handleNameChange = useCallback( + (event: React.ChangeEvent) => { + setName(event.target.value); + }, + [], + ); + + const handleDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + setDescription(event.target.value); + }, + [], + ); + + const handleTopicChange = useCallback( + (event: React.ChangeEvent) => { + setTopic(event.target.value); + }, + [], + ); + + const handleSensitiveChange = useCallback( + (event: React.ChangeEvent) => { + setSensitive(event.target.checked); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (id) { + // void dispatch( + // updateList({ + // id, + // title, + // exclusive, + // replies_policy: repliesPolicy, + // }), + // ).then(() => { + // return ''; + // }); + } else { + const payload: ApiCreateCollectionPayload = { + name, + description, + discoverable, + sensitive, + }; + if (topic) { + payload.tag_name = topic; + } + void dispatch( + createCollection({ + payload, + }), + ).then((result) => { + if (isFulfilled(result)) { + history.replace( + `/collections/${result.payload.collection.id}/edit`, + ); + history.push(`/collections`); + } + + return ''; + }); + } + }, + [id, dispatch, name, description, topic, discoverable, sensitive, history], + ); + + return ( +
+
+ + } + hint={ + + } + value={name} + onChange={handleNameChange} + maxLength={40} + /> +
+ +
+ + } + hint={ + + } + value={description} + onChange={handleDescriptionChange} + maxLength={100} + /> +
+ +
+ + } + hint={ + + } + value={topic} + onChange={handleTopicChange} + maxLength={40} + /> +
+ +
+ + } + hint={ + + } + checked={sensitive} + onChange={handleSensitiveChange} + /> +
+ +
+ +
+
+ ); +}; + +export const CollectionEditorPage: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { id } = useParams<{ id?: string }>(); + const collection = useAppSelector((state) => + id ? state.collections.collections[id] : undefined, + ); + const isEditMode = !!id; + const isLoading = isEditMode && !collection; + + useEffect(() => { + // if (id) { + // dispatch(fetchCollection(id)); + // } + }, [dispatch, id]); + + const pageTitle = intl.formatMessage(id ? messages.edit : messages.create); + + return ( + + + +
+ {isLoading ? ( + + ) : ( + + )} +
+ + + {pageTitle} + + +
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index 0b4b4c8d21..1afa43ebb8 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -16,7 +16,6 @@ import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Icon } from 'mastodon/components/icon'; import ScrollableList from 'mastodon/components/scrollable_list'; import { - createCollection, fetchAccountCollections, selectMyCollections, } from 'mastodon/reducers/slices/collections'; @@ -67,7 +66,7 @@ const ListItem: React.FC<{ return (
- + {name} @@ -94,24 +93,6 @@ export const Collections: React.FC<{ void dispatch(fetchAccountCollections({ accountId: me })); }, [dispatch, me]); - const addDummyCollection = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - - void dispatch( - createCollection({ - payload: { - name: 'Test Collection', - description: 'A useful test collection', - discoverable: true, - sensitive: false, - }, - }), - ); - }, - [dispatch], - ); - const emptyMessage = status === 'error' ? ( diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index c1a3fa895a..5ba78f599a 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -64,6 +64,7 @@ import { ListEdit, ListMembers, Collections, + CollectionsEditor, Blocks, DomainBlocks, Mutes, @@ -229,6 +230,12 @@ class SwitchingColumnsArea extends PureComponent { + {areCollectionsEnabled() && + + } + {areCollectionsEnabled() && + + } {areCollectionsEnabled() && } diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 4d31f6a4c4..7500575d7a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -50,6 +50,12 @@ export function Collections () { ); } +export function CollectionsEditor () { + return import('../../collections/editor').then( + module => ({default: module.CollectionEditorPage}) + ); +} + export function Status () { return import('../../status'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index c49db6f5f0..ecd35d3b19 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -212,21 +212,31 @@ "closed_registrations_modal.find_another_server": "Find another server", "closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!", "closed_registrations_modal.title": "Signing up on Mastodon", + "collections.collection_description": "Description", + "collections.collection_name": "Name", + "collections.collection_topic": "Topic", "collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.", "collections.create_collection": "Create collection", "collections.delete_collection": "Delete collection", + "collections.description_length_hint": "100 characters limit", "collections.error_loading_collections": "There was an error when trying to load your collections.", + "collections.mark_as_sensitive": "Mark as sensitive", + "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The title will still be visible.", + "collections.name_length_hint": "100 characters limit", "collections.no_collections_yet": "No collections yet.", + "collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.", "collections.view_collection": "View collection", "column.about": "About", "column.blocks": "Blocked users", "column.bookmarks": "Bookmarks", "column.collections": "My collections", "column.community": "Local timeline", + "column.create_collection": "Create collection", "column.create_list": "Create list", "column.direct": "Private mentions", "column.directory": "Browse profiles", "column.domain_blocks": "Blocked domains", + "column.edit_collection": "Edit collection", "column.edit_list": "Edit list", "column.favourites": "Favorites", "column.firehose": "Live feeds",