mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Allow managing collection accounts (#37812)
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
|||||||
ApiCreateCollectionPayload,
|
ApiCreateCollectionPayload,
|
||||||
ApiUpdateCollectionPayload,
|
ApiUpdateCollectionPayload,
|
||||||
ApiCollectionsJSON,
|
ApiCollectionsJSON,
|
||||||
|
WrappedCollectionAccountItem,
|
||||||
} from '../api_types/collections';
|
} from '../api_types/collections';
|
||||||
|
|
||||||
export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
|
export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
|
||||||
@@ -37,3 +38,14 @@ export const apiGetAccountCollections = (accountId: string) =>
|
|||||||
apiRequestGet<ApiCollectionsJSON>(
|
apiRequestGet<ApiCollectionsJSON>(
|
||||||
`v1_alpha/accounts/${accountId}/collections`,
|
`v1_alpha/accounts/${accountId}/collections`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const apiAddCollectionItem = (collectionId: string, accountId: string) =>
|
||||||
|
apiRequestPost<WrappedCollectionAccountItem>(
|
||||||
|
`v1_alpha/collections/${collectionId}/items`,
|
||||||
|
{ account_id: accountId },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const apiRemoveCollectionItem = (collectionId: string, itemId: string) =>
|
||||||
|
apiRequestDelete<WrappedCollectionAccountItem>(
|
||||||
|
`v1_alpha/collections/${collectionId}/items/${itemId}`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON
|
|||||||
* Nested account item
|
* Nested account item
|
||||||
*/
|
*/
|
||||||
interface CollectionAccountItem {
|
interface CollectionAccountItem {
|
||||||
|
id: string;
|
||||||
account_id?: string; // Only present when state is 'accepted' (or the collection is your own)
|
account_id?: string; // Only present when state is 'accepted' (or the collection is your own)
|
||||||
state: 'pending' | 'accepted' | 'rejected' | 'revoked';
|
state: 'pending' | 'accepted' | 'rejected' | 'revoked';
|
||||||
position: number;
|
position: number;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ interface AccountProps {
|
|||||||
defaultAction?: 'block' | 'mute';
|
defaultAction?: 'block' | 'mute';
|
||||||
withBio?: boolean;
|
withBio?: boolean;
|
||||||
withMenu?: boolean;
|
withMenu?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Account: React.FC<AccountProps> = ({
|
export const Account: React.FC<AccountProps> = ({
|
||||||
@@ -83,6 +84,7 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
defaultAction,
|
defaultAction,
|
||||||
withBio,
|
withBio,
|
||||||
withMenu = true,
|
withMenu = true,
|
||||||
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { signedIn } = useIdentity();
|
const { signedIn } = useIdentity();
|
||||||
@@ -353,6 +355,8 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
{button}
|
{button}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,14 +25,17 @@
|
|||||||
.popover {
|
.popover {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
max-height: max(200px, 30dvh);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
box-shadow: var(--dropdown-shadow);
|
box-shadow: var(--dropdown-shadow);
|
||||||
|
overflow-y: auto;
|
||||||
// backdrop-filter: $backdrop-blur-filter;
|
scrollbar-width: thin;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem {
|
.menuItem {
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&[aria-selected='true'] {
|
&[data-highlighted='true'] {
|
||||||
color: var(--color-text-on-brand-base);
|
color: var(--color-text-on-brand-base);
|
||||||
background: var(--color-bg-brand-base);
|
background: var(--color-bg-brand-base);
|
||||||
|
|
||||||
|
|||||||
@@ -18,24 +18,30 @@ import { FormFieldWrapper } from './form_field_wrapper';
|
|||||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||||
import { TextInput } from './text_input_field';
|
import { TextInput } from './text_input_field';
|
||||||
|
|
||||||
interface Item {
|
interface ComboboxItem {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComboboxItemState {
|
||||||
|
isSelected: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ComboboxProps<
|
interface ComboboxProps<
|
||||||
T extends Item,
|
T extends ComboboxItem,
|
||||||
> extends ComponentPropsWithoutRef<'input'> {
|
> extends ComponentPropsWithoutRef<'input'> {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
items: T[];
|
items: T[];
|
||||||
getItemId: (item: T) => string;
|
getItemId: (item: T) => string;
|
||||||
|
getIsItemSelected?: (item: T) => boolean;
|
||||||
getIsItemDisabled?: (item: T) => boolean;
|
getIsItemDisabled?: (item: T) => boolean;
|
||||||
renderItem: (item: T) => React.ReactElement;
|
renderItem: (item: T, state: ComboboxItemState) => React.ReactElement;
|
||||||
onSelectItem: (item: T) => void;
|
onSelectItem: (item: T) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T extends Item>
|
interface Props<T extends ComboboxItem>
|
||||||
extends ComboboxProps<T>, CommonFieldWrapperProps {}
|
extends ComboboxProps<T>, CommonFieldWrapperProps {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +49,7 @@ interface Props<T extends Item>
|
|||||||
* from a large list of options by searching or filtering.
|
* from a large list of options by searching or filtering.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ComboboxFieldWithRef = <T extends Item>(
|
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
|
||||||
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
|
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
|
||||||
ref: React.ForwardedRef<HTMLInputElement>,
|
ref: React.ForwardedRef<HTMLInputElement>,
|
||||||
) => (
|
) => (
|
||||||
@@ -61,7 +67,7 @@ export const ComboboxFieldWithRef = <T extends Item>(
|
|||||||
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
||||||
// (including its generic type) after wrapping it with `forwardRef`.
|
// (including its generic type) after wrapping it with `forwardRef`.
|
||||||
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
|
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
|
||||||
<T extends Item>(
|
<T extends ComboboxItem>(
|
||||||
props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
|
props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
|
||||||
): ReturnType<typeof ComboboxFieldWithRef>;
|
): ReturnType<typeof ComboboxFieldWithRef>;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -69,13 +75,15 @@ export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
|
|||||||
|
|
||||||
ComboboxField.displayName = 'ComboboxField';
|
ComboboxField.displayName = 'ComboboxField';
|
||||||
|
|
||||||
const ComboboxWithRef = <T extends Item>(
|
const ComboboxWithRef = <T extends ComboboxItem>(
|
||||||
{
|
{
|
||||||
value,
|
value,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items,
|
items,
|
||||||
getItemId,
|
getItemId,
|
||||||
getIsItemDisabled,
|
getIsItemDisabled,
|
||||||
|
getIsItemSelected,
|
||||||
|
disabled,
|
||||||
renderItem,
|
renderItem,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -88,6 +96,7 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>();
|
const inputRef = useRef<HTMLInputElement | null>();
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
|
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -101,11 +110,13 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
});
|
});
|
||||||
const showStatusMessageInMenu =
|
const showStatusMessageInMenu =
|
||||||
!!statusMessage && value.length > 0 && items.length === 0;
|
!!statusMessage && value.length > 0 && items.length === 0;
|
||||||
const hasMenuContent = items.length > 0 || showStatusMessageInMenu;
|
const hasMenuContent =
|
||||||
|
!disabled && (items.length > 0 || showStatusMessageInMenu);
|
||||||
const isMenuOpen = shouldMenuOpen && hasMenuContent;
|
const isMenuOpen = shouldMenuOpen && hasMenuContent;
|
||||||
|
|
||||||
const openMenu = useCallback(() => {
|
const openMenu = useCallback(() => {
|
||||||
setShouldMenuOpen(true);
|
setShouldMenuOpen(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeMenu = useCallback(() => {
|
const closeMenu = useCallback(() => {
|
||||||
@@ -118,6 +129,18 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
setHighlightedItemId(firstItemId);
|
setHighlightedItemId(firstItemId);
|
||||||
}, [getItemId, items]);
|
}, [getItemId, items]);
|
||||||
|
|
||||||
|
const highlightItem = useCallback((id: string | null) => {
|
||||||
|
setHighlightedItemId(id);
|
||||||
|
if (id) {
|
||||||
|
const itemElement = popoverRef.current?.querySelector<HTMLLIElement>(
|
||||||
|
`[data-item-id='${id}']`,
|
||||||
|
);
|
||||||
|
if (itemElement && popoverRef.current) {
|
||||||
|
scrollItemIntoView(itemElement, popoverRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(e);
|
onChange(e);
|
||||||
@@ -127,14 +150,14 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
[onChange, resetHighlight],
|
[onChange, resetHighlight],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHighlightItem = useCallback(
|
const handleItemMouseEnter = useCallback(
|
||||||
(e: React.MouseEvent<HTMLLIElement>) => {
|
(e: React.MouseEvent<HTMLLIElement>) => {
|
||||||
const { itemId } = e.currentTarget.dataset;
|
const { itemId } = e.currentTarget.dataset;
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
setHighlightedItemId(itemId);
|
highlightItem(itemId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[highlightItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectItem = useCallback(
|
const selectItem = useCallback(
|
||||||
@@ -175,10 +198,10 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
// If no item is highlighted yet, highlight the first or last
|
// If no item is highlighted yet, highlight the first or last
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
const firstItem = items.at(0);
|
const firstItem = items.at(0);
|
||||||
setHighlightedItemId(firstItem ? getItemId(firstItem) : null);
|
highlightItem(firstItem ? getItemId(firstItem) : null);
|
||||||
} else {
|
} else {
|
||||||
const lastItem = items.at(-1);
|
const lastItem = items.at(-1);
|
||||||
setHighlightedItemId(lastItem ? getItemId(lastItem) : null);
|
highlightItem(lastItem ? getItemId(lastItem) : null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If there is a highlighted item, select the next or previous item
|
// If there is a highlighted item, select the next or previous item
|
||||||
@@ -191,12 +214,12 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newHighlightedItem = items[newIndex];
|
const newHighlightedItem = items[newIndex];
|
||||||
setHighlightedItemId(
|
highlightItem(
|
||||||
newHighlightedItem ? getItemId(newHighlightedItem) : null,
|
newHighlightedItem ? getItemId(newHighlightedItem) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getItemId, highlightedItemId, items],
|
[getItemId, highlightItem, highlightedItemId, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
useOnClickOutside(wrapperRef, closeMenu);
|
useOnClickOutside(wrapperRef, closeMenu);
|
||||||
@@ -231,7 +254,6 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
if (isMenuOpen) {
|
if (isMenuOpen) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectHighlightedItem();
|
selectHighlightedItem();
|
||||||
closeMenu();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -271,9 +293,10 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
<TextInput
|
<TextInput
|
||||||
role='combobox'
|
role='combobox'
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
disabled={disabled}
|
||||||
aria-controls={listId}
|
aria-controls={listId}
|
||||||
aria-expanded={isMenuOpen ? 'true' : 'false'}
|
aria-expanded={isMenuOpen ? 'true' : 'false'}
|
||||||
aria-haspopup='true'
|
aria-haspopup='listbox'
|
||||||
aria-activedescendant={
|
aria-activedescendant={
|
||||||
isMenuOpen && highlightedItemId ? highlightedItemId : undefined
|
isMenuOpen && highlightedItemId ? highlightedItemId : undefined
|
||||||
}
|
}
|
||||||
@@ -311,11 +334,11 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
{isMenuOpen && statusMessage}
|
{isMenuOpen && statusMessage}
|
||||||
</span>
|
</span>
|
||||||
<Overlay
|
<Overlay
|
||||||
flip
|
|
||||||
show={isMenuOpen}
|
show={isMenuOpen}
|
||||||
offset={[0, 1]}
|
offset={[0, 1]}
|
||||||
placement='bottom-start'
|
placement='bottom-start'
|
||||||
onHide={closeMenu}
|
onHide={closeMenu}
|
||||||
|
ref={popoverRef}
|
||||||
target={inputRef as React.RefObject<HTMLInputElement>}
|
target={inputRef as React.RefObject<HTMLInputElement>}
|
||||||
container={wrapperRef}
|
container={wrapperRef}
|
||||||
popperConfig={{
|
popperConfig={{
|
||||||
@@ -331,19 +354,30 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const id = getItemId(item);
|
const id = getItemId(item);
|
||||||
const isDisabled = getIsItemDisabled?.(item);
|
const isDisabled = getIsItemDisabled?.(item);
|
||||||
|
const isHighlighted = id === highlightedItemId;
|
||||||
|
// If `getIsItemSelected` is defined, we assume 'multi-select'
|
||||||
|
// behaviour and don't set `aria-selected` based on highlight,
|
||||||
|
// but based on selected item state.
|
||||||
|
const isSelected = getIsItemSelected
|
||||||
|
? getIsItemSelected(item)
|
||||||
|
: isHighlighted;
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||||
<li
|
<li
|
||||||
key={id}
|
key={id}
|
||||||
role='option'
|
role='option'
|
||||||
className={classes.menuItem}
|
className={classes.menuItem}
|
||||||
aria-selected={id === highlightedItemId}
|
data-highlighted={isHighlighted}
|
||||||
|
aria-selected={isSelected}
|
||||||
aria-disabled={isDisabled}
|
aria-disabled={isDisabled}
|
||||||
data-item-id={id}
|
data-item-id={id}
|
||||||
onMouseEnter={handleHighlightItem}
|
onMouseEnter={handleItemMouseEnter}
|
||||||
onClick={handleSelectItem}
|
onClick={handleSelectItem}
|
||||||
>
|
>
|
||||||
{renderItem(item)}
|
{renderItem(item, {
|
||||||
|
isSelected,
|
||||||
|
isDisabled: isDisabled ?? false,
|
||||||
|
})}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -359,7 +393,7 @@ const ComboboxWithRef = <T extends Item>(
|
|||||||
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
||||||
// (including its generic type) after wrapping it with `forwardRef`.
|
// (including its generic type) after wrapping it with `forwardRef`.
|
||||||
export const Combobox = forwardRef(ComboboxWithRef) as {
|
export const Combobox = forwardRef(ComboboxWithRef) as {
|
||||||
<T extends Item>(
|
<T extends ComboboxItem>(
|
||||||
props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
|
props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
|
||||||
): ReturnType<typeof ComboboxWithRef>;
|
): ReturnType<typeof ComboboxWithRef>;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -406,3 +440,23 @@ function useGetA11yStatusMessage({
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCROLL_MARGIN = 6;
|
||||||
|
|
||||||
|
function scrollItemIntoView(item: HTMLElement, scrollParent: HTMLElement) {
|
||||||
|
const itemTopEdge = item.offsetTop;
|
||||||
|
const itemBottomEdge = itemTopEdge + item.offsetHeight;
|
||||||
|
|
||||||
|
// If item is above scroll area, scroll up
|
||||||
|
if (itemTopEdge < scrollParent.scrollTop) {
|
||||||
|
scrollParent.scrollTop = itemTopEdge - SCROLL_MARGIN;
|
||||||
|
}
|
||||||
|
// If item is below scroll area, scroll down
|
||||||
|
else if (
|
||||||
|
itemBottomEdge >
|
||||||
|
scrollParent.scrollTop + scrollParent.offsetHeight
|
||||||
|
) {
|
||||||
|
scrollParent.scrollTop =
|
||||||
|
itemBottomEdge - scrollParent.offsetHeight + SCROLL_MARGIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ export { Fieldset } from './fieldset';
|
|||||||
export { TextInputField, TextInput } from './text_input_field';
|
export { TextInputField, TextInput } from './text_input_field';
|
||||||
export { TextAreaField, TextArea } from './text_area_field';
|
export { TextAreaField, TextArea } from './text_area_field';
|
||||||
export { CheckboxField, Checkbox } from './checkbox_field';
|
export { CheckboxField, Checkbox } from './checkbox_field';
|
||||||
export { ComboboxField, Combobox } from './combobox_field';
|
export {
|
||||||
|
ComboboxField,
|
||||||
|
Combobox,
|
||||||
|
type ComboboxItemState,
|
||||||
|
} from './combobox_field';
|
||||||
export { RadioButtonField, RadioButton } from './radio_button_field';
|
export { RadioButtonField, RadioButton } from './radio_button_field';
|
||||||
export { ToggleField, Toggle } from './toggle_field';
|
export { ToggleField, Toggle } from './toggle_field';
|
||||||
export { SelectField, Select } from './select_field';
|
export { SelectField, Select } from './select_field';
|
||||||
|
|||||||
@@ -1,67 +1,363 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
|
||||||
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||||
|
import { Account } from 'mastodon/components/account';
|
||||||
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { FormStack } from 'mastodon/components/form_fields';
|
import { Callout } from 'mastodon/components/callout';
|
||||||
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { EmptyState } from 'mastodon/components/empty_state';
|
||||||
|
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
|
import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts';
|
||||||
|
import {
|
||||||
|
addCollectionItem,
|
||||||
|
removeCollectionItem,
|
||||||
|
} from 'mastodon/reducers/slices/collections';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import type { TempCollectionState } from './state';
|
import type { TempCollectionState } from './state';
|
||||||
import { getInitialState } from './state';
|
import { getCollectionEditorState } from './state';
|
||||||
|
import classes from './styles.module.scss';
|
||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepHeader } from './wizard_step_header';
|
||||||
|
|
||||||
|
const MIN_ACCOUNT_COUNT = 1;
|
||||||
|
const MAX_ACCOUNT_COUNT = 25;
|
||||||
|
|
||||||
|
const AddedAccountItem: React.FC<{
|
||||||
|
accountId: string;
|
||||||
|
isRemovable: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}> = ({ accountId, isRemovable, onRemove }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleRemoveAccount = useCallback(() => {
|
||||||
|
onRemove(accountId);
|
||||||
|
}, [accountId, onRemove]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Account minimal key={accountId} id={accountId}>
|
||||||
|
{isRemovable && (
|
||||||
|
<IconButton
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'collections.remove_account',
|
||||||
|
defaultMessage: 'Remove this account',
|
||||||
|
})}
|
||||||
|
icon='remove'
|
||||||
|
iconComponent={CancelIcon}
|
||||||
|
onClick={handleRemoveAccount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Account>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SuggestionItem {
|
||||||
|
id: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => {
|
||||||
|
const account = useAppSelector((state) => state.accounts.get(id));
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Avatar account={account} />
|
||||||
|
<DisplayName account={account} />
|
||||||
|
{isSelected && (
|
||||||
|
<Icon
|
||||||
|
id='checked'
|
||||||
|
icon={CheckIcon}
|
||||||
|
className={classes.selectedSuggestionIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAccountItem = (item: SuggestionItem) => (
|
||||||
|
<SuggestedAccountItem id={item.id} isSelected={item.isSelected} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemId = (item: SuggestionItem) => item.id;
|
||||||
|
const getIsItemSelected = (item: SuggestionItem) => item.isSelected;
|
||||||
|
|
||||||
export const CollectionAccounts: React.FC<{
|
export const CollectionAccounts: React.FC<{
|
||||||
collection?: ApiCollectionJSON | null;
|
collection?: ApiCollectionJSON | null;
|
||||||
}> = ({ collection }) => {
|
}> = ({ collection }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation<TempCollectionState>();
|
const location = useLocation<TempCollectionState>();
|
||||||
|
const { id, initialItemIds } = getCollectionEditorState(
|
||||||
|
collection,
|
||||||
|
location.state,
|
||||||
|
);
|
||||||
|
const isEditMode = !!id;
|
||||||
|
const collectionItems = collection?.items;
|
||||||
|
|
||||||
const { id } = getInitialState(collection, location.state);
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
// This state is only used when creating a new collection.
|
||||||
|
// In edit mode, the collection will be updated instantly
|
||||||
|
const [addedAccountIds, setAccountIds] = useState(initialItemIds);
|
||||||
|
const accountIds = useMemo(
|
||||||
|
() =>
|
||||||
|
isEditMode
|
||||||
|
? (collectionItems
|
||||||
|
?.map((item) => item.account_id)
|
||||||
|
.filter((id): id is string => !!id) ?? [])
|
||||||
|
: addedAccountIds,
|
||||||
|
[isEditMode, collectionItems, addedAccountIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
|
||||||
|
const hasMinAccounts = accountIds.length === MIN_ACCOUNT_COUNT;
|
||||||
|
const hasTooFewAccounts = accountIds.length < MIN_ACCOUNT_COUNT;
|
||||||
|
const canSubmit = !hasTooFewAccounts;
|
||||||
|
|
||||||
|
const {
|
||||||
|
accountIds: suggestedAccountIds,
|
||||||
|
isLoading: isLoadingSuggestions,
|
||||||
|
searchAccounts,
|
||||||
|
} = useSearchAccounts();
|
||||||
|
|
||||||
|
const suggestedItems = suggestedAccountIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
isSelected: accountIds.includes(id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSearchValueChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
|
searchAccounts(e.target.value);
|
||||||
|
},
|
||||||
|
[searchAccounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAccountItem = useCallback((item: SuggestionItem) => {
|
||||||
|
setAccountIds((ids) =>
|
||||||
|
ids.includes(item.id)
|
||||||
|
? ids.filter((id) => id !== item.id)
|
||||||
|
: [...ids, item.id],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const instantRemoveAccountItem = useCallback(
|
||||||
|
(accountId: string) => {
|
||||||
|
const itemId = collectionItems?.find(
|
||||||
|
(item) => item.account_id === accountId,
|
||||||
|
)?.id;
|
||||||
|
if (itemId && id) {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'collections.confirm_account_removal',
|
||||||
|
defaultMessage:
|
||||||
|
'Are you sure you want to remove this account from this collection?',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
void dispatch(removeCollectionItem({ collectionId: id, itemId }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[collectionItems, dispatch, id, intl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const instantToggleAccountItem = useCallback(
|
||||||
|
(item: SuggestionItem) => {
|
||||||
|
if (accountIds.includes(item.id)) {
|
||||||
|
instantRemoveAccountItem(item.id);
|
||||||
|
} else {
|
||||||
|
if (id) {
|
||||||
|
void dispatch(
|
||||||
|
addCollectionItem({ collectionId: id, accountId: item.id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accountIds, dispatch, id, instantRemoveAccountItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveAccountItem = useCallback(
|
||||||
|
(accountId: string) => {
|
||||||
|
if (isEditMode) {
|
||||||
|
instantRemoveAccountItem(accountId);
|
||||||
|
} else {
|
||||||
|
setAccountIds((ids) => ids.filter((id) => id !== accountId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isEditMode, instantRemoveAccountItem],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: React.FormEvent) => {
|
(e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
history.push(`/collections/new/details`);
|
history.push(`/collections/new/details`, {
|
||||||
|
account_ids: accountIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id, history],
|
[canSubmit, id, history, accountIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormStack as='form' onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} className={classes.form}>
|
||||||
{!id && (
|
<FormStack className={classes.formFieldStack}>
|
||||||
<WizardStepHeader
|
{!id && (
|
||||||
step={1}
|
<WizardStepHeader
|
||||||
title={
|
step={1}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.create.accounts_title'
|
||||||
|
defaultMessage='Who will you feature in this collection?'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.create.accounts_subtitle'
|
||||||
|
defaultMessage='Only accounts you follow who have opted into discovery can be added.'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ComboboxField
|
||||||
|
label={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collections.create.accounts_title'
|
id='collections.search_accounts_label'
|
||||||
defaultMessage='Who will you feature in this collection?'
|
defaultMessage='Search for accounts to add…'
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
description={
|
hint={
|
||||||
<FormattedMessage
|
hasMaxAccounts ? (
|
||||||
id='collections.create.accounts_subtitle'
|
<FormattedMessage
|
||||||
defaultMessage='Only accounts you follow who have opted into discovery can be added.'
|
id='collections.search_accounts_max_reached'
|
||||||
/>
|
defaultMessage='You have added the maximum number of accounts'
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
value={hasMaxAccounts ? '' : searchValue}
|
||||||
|
onChange={handleSearchValueChange}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
disabled={hasMaxAccounts}
|
||||||
|
isLoading={isLoadingSuggestions}
|
||||||
|
items={suggestedItems}
|
||||||
|
getItemId={getItemId}
|
||||||
|
getIsItemSelected={getIsItemSelected}
|
||||||
|
renderItem={renderAccountItem}
|
||||||
|
onSelectItem={
|
||||||
|
isEditMode ? instantToggleAccountItem : toggleAccountItem
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className='actions'>
|
{hasMinAccounts && (
|
||||||
<Button type='submit'>
|
<Callout>
|
||||||
{id ? (
|
|
||||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collections.continue'
|
id='collections.hints.can_not_remove_more_accounts'
|
||||||
defaultMessage='Continue'
|
defaultMessage='Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.'
|
||||||
|
values={{ count: MIN_ACCOUNT_COUNT }}
|
||||||
/>
|
/>
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classes.scrollableWrapper}>
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='collection-items'
|
||||||
|
className={classes.scrollableInner}
|
||||||
|
emptyMessage={
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.accounts.empty_title'
|
||||||
|
defaultMessage='This collection is empty'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.accounts.empty_description'
|
||||||
|
defaultMessage='Add up to {count} accounts you follow'
|
||||||
|
values={{
|
||||||
|
count: MAX_ACCOUNT_COUNT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
// TODO: Re-add `bindToDocument={!multiColumn}`
|
||||||
|
>
|
||||||
|
{accountIds.map((accountId) => (
|
||||||
|
<AddedAccountItem
|
||||||
|
key={accountId}
|
||||||
|
accountId={accountId}
|
||||||
|
isRemovable={!isEditMode || !hasMinAccounts}
|
||||||
|
onRemove={handleRemoveAccountItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
</div>
|
||||||
|
</FormStack>
|
||||||
|
{!isEditMode && (
|
||||||
|
<div className={classes.stickyFooter}>
|
||||||
|
{hasTooFewAccounts ? (
|
||||||
|
<Callout icon={false} className={classes.submitDisabledCallout}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.hints.add_more_accounts'
|
||||||
|
defaultMessage='Add at least {count, plural, one {# account} other {# accounts}} to continue'
|
||||||
|
values={{ count: MIN_ACCOUNT_COUNT }}
|
||||||
|
/>
|
||||||
|
</Callout>
|
||||||
|
) : (
|
||||||
|
<div className={classes.actionWrapper}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.hints.accounts_counter'
|
||||||
|
defaultMessage='{count} / {max} accounts'
|
||||||
|
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
|
||||||
|
>
|
||||||
|
{(text) => (
|
||||||
|
<div className={classes.itemCountReadout}>{text}</div>
|
||||||
|
)}
|
||||||
|
</FormattedMessage>
|
||||||
|
{canSubmit && (
|
||||||
|
<Button type='submit'>
|
||||||
|
{id ? (
|
||||||
|
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.continue'
|
||||||
|
defaultMessage='Continue'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</FormStack>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { updateCollection } from 'mastodon/reducers/slices/collections';
|
|||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import type { TempCollectionState } from './state';
|
import type { TempCollectionState } from './state';
|
||||||
import { getInitialState } from './state';
|
import { getCollectionEditorState } from './state';
|
||||||
|
import classes from './styles.module.scss';
|
||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepHeader } from './wizard_step_header';
|
||||||
|
|
||||||
export const CollectionDetails: React.FC<{
|
export const CollectionDetails: React.FC<{
|
||||||
@@ -26,10 +27,8 @@ export const CollectionDetails: React.FC<{
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation<TempCollectionState>();
|
const location = useLocation<TempCollectionState>();
|
||||||
|
|
||||||
const { id, initialName, initialDescription, initialTopic } = getInitialState(
|
const { id, initialName, initialDescription, initialTopic, initialItemIds } =
|
||||||
collection,
|
getCollectionEditorState(collection, location.state);
|
||||||
location.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [name, setName] = useState(initialName);
|
const [name, setName] = useState(initialName);
|
||||||
const [description, setDescription] = useState(initialDescription);
|
const [description, setDescription] = useState(initialDescription);
|
||||||
@@ -76,13 +75,14 @@ export const CollectionDetails: React.FC<{
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
tag_name: topic || null,
|
tag_name: topic || null,
|
||||||
|
account_ids: initialItemIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
history.replace('/collections/new', payload);
|
history.replace('/collections/new', payload);
|
||||||
history.push('/collections/new/settings', payload);
|
history.push('/collections/new/settings', payload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id, dispatch, name, description, topic, history],
|
[id, name, description, topic, dispatch, history, initialItemIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,7 +155,7 @@ export const CollectionDetails: React.FC<{
|
|||||||
maxLength={40}
|
maxLength={40}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='actions'>
|
<div className={classes.actionWrapper}>
|
||||||
<Button type='submit'>
|
<Button type='submit'>
|
||||||
{id ? (
|
{id ? (
|
||||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import type { TempCollectionState } from './state';
|
import type { TempCollectionState } from './state';
|
||||||
import { getInitialState } from './state';
|
import { getCollectionEditorState } from './state';
|
||||||
|
import classes from './styles.module.scss';
|
||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepHeader } from './wizard_step_header';
|
||||||
|
|
||||||
export const CollectionSettings: React.FC<{
|
export const CollectionSettings: React.FC<{
|
||||||
@@ -35,8 +36,8 @@ export const CollectionSettings: React.FC<{
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation<TempCollectionState>();
|
const location = useLocation<TempCollectionState>();
|
||||||
|
|
||||||
const { id, initialDiscoverable, initialSensitive, ...temporaryState } =
|
const { id, initialDiscoverable, initialSensitive, ...editorState } =
|
||||||
getInitialState(collection, location.state);
|
getCollectionEditorState(collection, location.state);
|
||||||
|
|
||||||
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
|
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
|
||||||
const [sensitive, setSensitive] = useState(initialSensitive);
|
const [sensitive, setSensitive] = useState(initialSensitive);
|
||||||
@@ -71,13 +72,14 @@ export const CollectionSettings: React.FC<{
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const payload: ApiCreateCollectionPayload = {
|
const payload: ApiCreateCollectionPayload = {
|
||||||
name: temporaryState.initialName,
|
name: editorState.initialName,
|
||||||
description: temporaryState.initialDescription,
|
description: editorState.initialDescription,
|
||||||
discoverable,
|
discoverable,
|
||||||
sensitive,
|
sensitive,
|
||||||
|
account_ids: editorState.initialItemIds,
|
||||||
};
|
};
|
||||||
if (temporaryState.initialTopic) {
|
if (editorState.initialTopic) {
|
||||||
payload.tag_name = temporaryState.initialTopic;
|
payload.tag_name = editorState.initialTopic;
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispatch(
|
void dispatch(
|
||||||
@@ -94,7 +96,7 @@ export const CollectionSettings: React.FC<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id, discoverable, sensitive, dispatch, history, temporaryState],
|
[id, discoverable, sensitive, dispatch, history, editorState],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,7 +182,7 @@ export const CollectionSettings: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
<div className='actions'>
|
<div className={classes.actionWrapper}>
|
||||||
<Button type='submit'>
|
<Button type='submit'>
|
||||||
{id ? (
|
{id ? (
|
||||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type TempCollectionState =
|
|||||||
* Resolve initial editor state. Temporary location state
|
* Resolve initial editor state. Temporary location state
|
||||||
* trumps stored data, otherwise initial values are returned.
|
* trumps stored data, otherwise initial values are returned.
|
||||||
*/
|
*/
|
||||||
export function getInitialState(
|
export function getCollectionEditorState(
|
||||||
collection: ApiCollectionJSON | null | undefined,
|
collection: ApiCollectionJSON | null | undefined,
|
||||||
locationState: TempCollectionState,
|
locationState: TempCollectionState,
|
||||||
) {
|
) {
|
||||||
@@ -27,10 +27,19 @@ export function getInitialState(
|
|||||||
language = '',
|
language = '',
|
||||||
discoverable = true,
|
discoverable = true,
|
||||||
sensitive = false,
|
sensitive = false,
|
||||||
|
items,
|
||||||
} = collection ?? {};
|
} = collection ?? {};
|
||||||
|
|
||||||
|
const collectionItemIds =
|
||||||
|
items?.map((item) => item.account_id).filter(onlyExistingIds) ?? [];
|
||||||
|
|
||||||
|
const initialItemIds = (
|
||||||
|
locationState?.account_ids ?? collectionItemIds
|
||||||
|
).filter(onlyExistingIds);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
initialItemIds,
|
||||||
initialName: locationState?.name ?? name,
|
initialName: locationState?.name ?? name,
|
||||||
initialDescription: locationState?.description ?? description,
|
initialDescription: locationState?.description ?? description,
|
||||||
initialTopic: locationState?.tag_name ?? tag?.name ?? '',
|
initialTopic: locationState?.tag_name ?? tag?.name ?? '',
|
||||||
@@ -39,3 +48,5 @@ export function getInitialState(
|
|||||||
initialSensitive: locationState?.sensitive ?? sensitive,
|
initialSensitive: locationState?.sensitive ?? sensitive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onlyExistingIds = (id?: string): id is string => !!id;
|
||||||
|
|||||||
@@ -13,3 +13,76 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Make form stretch full height of the column */
|
||||||
|
.form {
|
||||||
|
--bottom-spacing: 0;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
padding-bottom: var(--bottom-spacing);
|
||||||
|
|
||||||
|
@media (width < 760px) {
|
||||||
|
--bottom-spacing: var(--mobile-bottom-nav-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedSuggestionIcon {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 100%;
|
||||||
|
color: var(--color-text-on-brand-base);
|
||||||
|
background: var(--color-bg-brand-base);
|
||||||
|
|
||||||
|
[data-highlighted='true'] & {
|
||||||
|
color: var(--color-bg-brand-base);
|
||||||
|
background: var(--color-text-on-brand-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldStack {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollableWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
margin-inline: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollableInner {
|
||||||
|
margin-inline: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitDisabledCallout {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stickyFooter {
|
||||||
|
position: sticky;
|
||||||
|
bottom: var(--bottom-spacing);
|
||||||
|
padding: 16px;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
var(--color-bg-primary) 32px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemCountReadout {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min-content;
|
||||||
|
min-width: 240px;
|
||||||
|
margin-inline: auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,10 +165,11 @@ const ListMembers: React.FC<{
|
|||||||
const [mode, setMode] = useState<Mode>('remove');
|
const [mode, setMode] = useState<Mode>('remove');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accountIds: searchAccountIds = [],
|
accountIds: searchAccountIds,
|
||||||
isLoading: loadingSearchResults,
|
isLoading: loadingSearchResults,
|
||||||
searchAccounts: handleSearch,
|
searchAccounts: handleSearch,
|
||||||
} = useSearchAccounts({
|
} = useSearchAccounts({
|
||||||
|
resetOnInputClear: false,
|
||||||
onSettled: (value) => {
|
onSettled: (value) => {
|
||||||
if (value.trim().length === 0) {
|
if (value.trim().length === 0) {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
|||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
export function useSearchAccounts({
|
export function useSearchAccounts({
|
||||||
|
resetOnInputClear = true,
|
||||||
onSettled,
|
onSettled,
|
||||||
}: {
|
}: {
|
||||||
onSettled?: (value: string) => void;
|
onSettled?: (value: string) => void;
|
||||||
|
resetOnInputClear?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [accountIds, setAccountIds] = useState<string[]>();
|
const [accountIds, setAccountIds] = useState<string[]>([]);
|
||||||
const [loadingState, setLoadingState] = useState<
|
const [loadingState, setLoadingState] = useState<
|
||||||
'idle' | 'loading' | 'error'
|
'idle' | 'loading' | 'error'
|
||||||
>('idle');
|
>('idle');
|
||||||
@@ -29,6 +31,9 @@ export function useSearchAccounts({
|
|||||||
|
|
||||||
if (value.trim().length === 0) {
|
if (value.trim().length === 0) {
|
||||||
onSettled?.('');
|
onSettled?.('');
|
||||||
|
if (resetOnInputClear) {
|
||||||
|
setAccountIds([]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,9 +244,12 @@
|
|||||||
"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.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",
|
"closed_registrations_modal.title": "Signing up on Mastodon",
|
||||||
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
|
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
|
||||||
|
"collections.accounts.empty_description": "Add up to {count} accounts you follow",
|
||||||
|
"collections.accounts.empty_title": "This collection is empty",
|
||||||
"collections.collection_description": "Description",
|
"collections.collection_description": "Description",
|
||||||
"collections.collection_name": "Name",
|
"collections.collection_name": "Name",
|
||||||
"collections.collection_topic": "Topic",
|
"collections.collection_topic": "Topic",
|
||||||
|
"collections.confirm_account_removal": "Are you sure you want to remove this account from this collection?",
|
||||||
"collections.content_warning": "Content warning",
|
"collections.content_warning": "Content warning",
|
||||||
"collections.continue": "Continue",
|
"collections.continue": "Continue",
|
||||||
"collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.",
|
"collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.",
|
||||||
@@ -261,6 +264,9 @@
|
|||||||
"collections.edit_details": "Edit basic details",
|
"collections.edit_details": "Edit basic details",
|
||||||
"collections.edit_settings": "Edit settings",
|
"collections.edit_settings": "Edit settings",
|
||||||
"collections.error_loading_collections": "There was an error when trying to load your collections.",
|
"collections.error_loading_collections": "There was an error when trying to load your collections.",
|
||||||
|
"collections.hints.accounts_counter": "{count} / {max} accounts",
|
||||||
|
"collections.hints.add_more_accounts": "Add at least {count, plural, one {# account} other {# accounts}} to continue",
|
||||||
|
"collections.hints.can_not_remove_more_accounts": "Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.",
|
||||||
"collections.last_updated_at": "Last updated: {date}",
|
"collections.last_updated_at": "Last updated: {date}",
|
||||||
"collections.manage_accounts": "Manage accounts",
|
"collections.manage_accounts": "Manage accounts",
|
||||||
"collections.manage_accounts_in_collection": "Manage accounts in this collection",
|
"collections.manage_accounts_in_collection": "Manage accounts in this collection",
|
||||||
@@ -269,6 +275,9 @@
|
|||||||
"collections.name_length_hint": "100 characters limit",
|
"collections.name_length_hint": "100 characters limit",
|
||||||
"collections.new_collection": "New collection",
|
"collections.new_collection": "New collection",
|
||||||
"collections.no_collections_yet": "No collections yet.",
|
"collections.no_collections_yet": "No collections yet.",
|
||||||
|
"collections.remove_account": "Remove this account",
|
||||||
|
"collections.search_accounts_label": "Search for accounts to add…",
|
||||||
|
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
|
||||||
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
||||||
"collections.view_collection": "View collection",
|
"collections.view_collection": "View collection",
|
||||||
"collections.visibility_public": "Public",
|
"collections.visibility_public": "Public",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
apiUpdateCollection,
|
apiUpdateCollection,
|
||||||
apiGetCollection,
|
apiGetCollection,
|
||||||
apiDeleteCollection,
|
apiDeleteCollection,
|
||||||
|
apiAddCollectionItem,
|
||||||
|
apiRemoveCollectionItem,
|
||||||
} from '@/mastodon/api/collections';
|
} from '@/mastodon/api/collections';
|
||||||
import type {
|
import type {
|
||||||
ApiCollectionJSON,
|
ApiCollectionJSON,
|
||||||
@@ -131,6 +133,32 @@ const collectionSlice = createSlice({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adding an account to a collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
builder.addCase(addCollectionItem.fulfilled, (state, action) => {
|
||||||
|
const { collection_item } = action.payload;
|
||||||
|
const { collectionId } = action.meta.arg;
|
||||||
|
|
||||||
|
state.collections[collectionId]?.items.push(collection_item);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removing an account from a collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
builder.addCase(removeCollectionItem.fulfilled, (state, action) => {
|
||||||
|
const { itemId, collectionId } = action.meta.arg;
|
||||||
|
|
||||||
|
const collection = state.collections[collectionId];
|
||||||
|
if (collection) {
|
||||||
|
collection.items = collection.items.filter(
|
||||||
|
(item) => item.id !== itemId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +197,18 @@ export const deleteCollection = createDataLoadingThunk(
|
|||||||
apiDeleteCollection(collectionId),
|
apiDeleteCollection(collectionId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const addCollectionItem = createDataLoadingThunk(
|
||||||
|
`${collectionSlice.name}/addCollectionItem`,
|
||||||
|
({ collectionId, accountId }: { collectionId: string; accountId: string }) =>
|
||||||
|
apiAddCollectionItem(collectionId, accountId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const removeCollectionItem = createDataLoadingThunk(
|
||||||
|
`${collectionSlice.name}/removeCollectionItem`,
|
||||||
|
({ collectionId, itemId }: { collectionId: string; itemId: string }) =>
|
||||||
|
apiRemoveCollectionItem(collectionId, itemId),
|
||||||
|
);
|
||||||
|
|
||||||
export const collections = collectionSlice.reducer;
|
export const collections = collectionSlice.reducer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user