mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge pull request #3410 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 8e7c3973dc
This commit is contained in:
@@ -187,7 +187,7 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
debug_inspector (1.2.0)
|
||||
devise (5.0.1)
|
||||
devise (5.0.2)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 7.0)
|
||||
|
||||
22
app/controllers/admin/collections_controller.rb
Normal file
22
app/controllers/admin/collections_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class CollectionsController < BaseController
|
||||
before_action :set_account
|
||||
before_action :set_collection, only: :show
|
||||
|
||||
def show
|
||||
authorize @collection, :show?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_collection
|
||||
@collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -50,7 +50,7 @@ module Admin
|
||||
private
|
||||
|
||||
def filtered_reports
|
||||
ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account)
|
||||
ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account, :collections)
|
||||
end
|
||||
|
||||
def filter_params
|
||||
@@ -58,7 +58,7 @@ module Admin
|
||||
end
|
||||
|
||||
def set_report
|
||||
@report = Report.find(params[:id])
|
||||
@report = Report.includes(collections: :accepted_collection_items).find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,26 @@ export interface ApiAccountRoleJSON {
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ApiFeaturePolicy =
|
||||
| 'public'
|
||||
| 'followers'
|
||||
| 'following'
|
||||
| 'disabled'
|
||||
| 'unsupported_policy';
|
||||
|
||||
type ApiUserFeaturePolicy =
|
||||
| 'automatic'
|
||||
| 'manual'
|
||||
| 'denied'
|
||||
| 'missing'
|
||||
| 'unknown';
|
||||
|
||||
interface ApiFeaturePolicyJSON {
|
||||
automatic: ApiFeaturePolicy[];
|
||||
manual: ApiFeaturePolicy[];
|
||||
current_user: ApiUserFeaturePolicy;
|
||||
}
|
||||
|
||||
// See app/serializers/rest/account_serializer.rb
|
||||
export interface BaseApiAccountJSON {
|
||||
acct: string;
|
||||
@@ -23,6 +43,7 @@ export interface BaseApiAccountJSON {
|
||||
indexable: boolean;
|
||||
display_name: string;
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
feature_approval: ApiFeaturePolicyJSON;
|
||||
fields: ApiAccountFieldJSON[];
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
interface AccountBioProps {
|
||||
className: string;
|
||||
className?: string;
|
||||
accountId: string;
|
||||
showDropdown?: boolean;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ export const WithError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AutoSize: Story = {
|
||||
args: {
|
||||
autoSize: true,
|
||||
defaultValue: 'This textarea will grow as you type more lines.',
|
||||
},
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render(args) {
|
||||
return <TextArea {...args} />;
|
||||
|
||||
@@ -3,12 +3,16 @@ import { forwardRef, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
import TextAreaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './text_input.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
|
||||
type TextAreaProps =
|
||||
| ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
|
||||
| ({ autoSize: true } & TextareaAutosizeProps);
|
||||
|
||||
/**
|
||||
* A simple form field for multi-line text.
|
||||
@@ -17,45 +21,56 @@ interface Props
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
|
||||
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
export const TextAreaField = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextAreaProps & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
));
|
||||
|
||||
TextAreaField.displayName = 'TextAreaField';
|
||||
|
||||
export const TextArea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
ComponentPropsWithoutRef<'textarea'>
|
||||
>(({ className, onKeyDown, ...otherProps }, ref) => {
|
||||
const handleSubmitHotkey = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
const targetForm = e.currentTarget.form;
|
||||
targetForm?.requestSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown],
|
||||
);
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
({ className, onKeyDown, autoSize, ...otherProps }, ref) => {
|
||||
const handleSubmitHotkey = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
const targetForm = e.currentTarget.form;
|
||||
targetForm?.requestSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown],
|
||||
);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...otherProps}
|
||||
onKeyDown={handleSubmitHotkey}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (autoSize) {
|
||||
return (
|
||||
<TextAreaAutosize
|
||||
{...(otherProps as TextareaAutosizeProps)}
|
||||
onKeyDown={handleSubmitHotkey}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...otherProps}
|
||||
onKeyDown={handleSubmitHotkey}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextArea } from '@/flavours/glitch/components/form_fields';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
|
||||
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.bio_modal.add_title',
|
||||
defaultMessage: 'Add bio',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.bio_modal.edit_title',
|
||||
defaultMessage: 'Edit bio',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_BIO_LENGTH = 500;
|
||||
|
||||
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const [newBio, setNewBio] = useState(account?.note_plain ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
setNewBio(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePickEmoji = useCallback((emoji: string) => {
|
||||
setNewBio((prev) => {
|
||||
const position = textAreaRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!account) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(
|
||||
account.note_plain ? messages.editTitle : messages.addTitle,
|
||||
)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={onClose} // To be implemented
|
||||
onClose={onClose}
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextArea
|
||||
value={newBio}
|
||||
ref={textAreaRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
autoSize
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newBio.length}
|
||||
maxLength={MAX_BIO_LENGTH}
|
||||
id={counterId}
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const CharCounter = polymorphicForwardRef<
|
||||
'p',
|
||||
{ currentLength: number; maxLength: number }
|
||||
>(({ currentLength, maxLength, as: Component = 'p' }, ref) => (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
classes.counter,
|
||||
currentLength > maxLength && classes.counterError,
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.char_counter'
|
||||
defaultMessage='{currentLength}/{maxLength} characters'
|
||||
values={{ currentLength, maxLength }}
|
||||
/>
|
||||
</Component>
|
||||
));
|
||||
CharCounter.displayName = 'CharCounter';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { isPlainObject } from '@reduxjs/toolkit';
|
||||
|
||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
export const EmojiPicker: FC<{ onPick: (emoji: string) => void }> = ({
|
||||
onPick,
|
||||
}) => {
|
||||
const handlePick = useCallback(
|
||||
(emoji: unknown) => {
|
||||
if (isPlainObject(emoji)) {
|
||||
if ('native' in emoji && typeof emoji.native === 'string') {
|
||||
onPick(emoji.native);
|
||||
} else if (
|
||||
'shortcode' in emoji &&
|
||||
typeof emoji.shortcode === 'string'
|
||||
) {
|
||||
onPick(`:${emoji.shortcode}:`);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onPick],
|
||||
);
|
||||
return <EmojiPickerDropdown onPickEmoji={handlePick} />;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextInput } from '@/flavours/glitch/components/form_fields';
|
||||
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
|
||||
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.name_modal.add_title',
|
||||
defaultMessage: 'Add display name',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.name_modal.edit_title',
|
||||
defaultMessage: 'Edit display name',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_NAME_LENGTH = 30;
|
||||
|
||||
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const [newName, setNewName] = useState(account?.display_name ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
setNewName(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePickEmoji = useCallback((emoji: string) => {
|
||||
setNewName((prev) => {
|
||||
const position = inputRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.editTitle)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={onClose} // To be implemented
|
||||
onClose={onClose}
|
||||
noCloseOnConfirm
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextInput
|
||||
value={newName}
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newName.length}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
id={counterId}
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
const buttonMessage = defineMessage({
|
||||
id: 'account_edit.section_edit_button',
|
||||
defaultMessage: 'Edit',
|
||||
});
|
||||
|
||||
interface AccountEditSectionProps {
|
||||
title: MessageDescriptor;
|
||||
description?: MessageDescriptor;
|
||||
showDescription?: boolean;
|
||||
onEdit?: () => void;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
extraButtons?: ReactNode;
|
||||
}
|
||||
|
||||
export const AccountEditSection: FC<AccountEditSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
showDescription,
|
||||
onEdit,
|
||||
children,
|
||||
className,
|
||||
extraButtons,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<section className={classNames(className, classes.section)}>
|
||||
<header className={classes.sectionHeader}>
|
||||
<h3 className={classes.sectionTitle}>
|
||||
<FormattedMessage {...title} />
|
||||
</h3>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
icon='pencil'
|
||||
iconComponent={EditIcon}
|
||||
onClick={onEdit}
|
||||
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
|
||||
/>
|
||||
)}
|
||||
{extraButtons}
|
||||
</header>
|
||||
{showDescription && (
|
||||
<p className={classes.sectionSubtitle}>
|
||||
<FormattedMessage {...description} />
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ModalType } from '@/flavours/glitch/actions/modal';
|
||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||
import { Avatar } from '@/flavours/glitch/components/avatar';
|
||||
import { Column } from '@/flavours/glitch/components/column';
|
||||
import { ColumnHeader } from '@/flavours/glitch/components/column_header';
|
||||
import { DisplayNameSimple } from '@/flavours/glitch/components/display_name/simple';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||
|
||||
import { AccountEditSection } from './components/section';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
displayNameTitle: {
|
||||
id: 'account_edit.display_name.title',
|
||||
defaultMessage: 'Display name',
|
||||
},
|
||||
displayNamePlaceholder: {
|
||||
id: 'account_edit.display_name.placeholder',
|
||||
defaultMessage:
|
||||
'Your display name is how your name appears on your profile and in timelines.',
|
||||
},
|
||||
bioTitle: {
|
||||
id: 'account_edit.bio.title',
|
||||
defaultMessage: 'Bio',
|
||||
},
|
||||
bioPlaceholder: {
|
||||
id: 'account_edit.bio.placeholder',
|
||||
defaultMessage: 'Add a short introduction to help others identify you.',
|
||||
},
|
||||
customFieldsTitle: {
|
||||
id: 'account_edit.custom_fields.title',
|
||||
defaultMessage: 'Custom fields',
|
||||
},
|
||||
customFieldsPlaceholder: {
|
||||
id: 'account_edit.custom_fields.placeholder',
|
||||
defaultMessage:
|
||||
'Add your pronouns, external links, or anything else you’d like to share.',
|
||||
},
|
||||
featuredHashtagsTitle: {
|
||||
id: 'account_edit.featured_hashtags.title',
|
||||
defaultMessage: 'Featured hashtags',
|
||||
},
|
||||
featuredHashtagsPlaceholder: {
|
||||
id: 'account_edit.featured_hashtags.placeholder',
|
||||
defaultMessage:
|
||||
'Help others identify, and have quick access to, your favorite topics.',
|
||||
},
|
||||
profileTabTitle: {
|
||||
id: 'account_edit.profile_tab.title',
|
||||
defaultMessage: 'Profile tab settings',
|
||||
},
|
||||
profileTabSubtitle: {
|
||||
id: 'account_edit.profile_tab.subtitle',
|
||||
defaultMessage: 'Customize the tabs on your profile and what they display.',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleOpenModal = useCallback(
|
||||
(type: ModalType, props?: Record<string, unknown>) => {
|
||||
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const handleNameEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_NAME');
|
||||
}, [handleOpenModal]);
|
||||
const handleBioEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||
}, [handleOpenModal]);
|
||||
|
||||
if (!accountId) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
@@ -30,6 +99,8 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const headerSrc = autoPlayGif ? account.header : account.header_static;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<ColumnHeader
|
||||
@@ -37,7 +108,7 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
id: 'account_edit.column_title',
|
||||
defaultMessage: 'Edit Profile',
|
||||
})}
|
||||
className={classes.header}
|
||||
className={classes.columnHeader}
|
||||
showBackButton
|
||||
extraButton={
|
||||
<Link to={`/@${account.acct}`} className='button'>
|
||||
@@ -48,6 +119,48 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<header>
|
||||
<div className={classes.profileImage}>
|
||||
{headerSrc && <img src={headerSrc} alt='' />}
|
||||
</div>
|
||||
<Avatar account={account} size={80} className={classes.avatar} />
|
||||
</header>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.displayNameTitle}
|
||||
description={messages.displayNamePlaceholder}
|
||||
showDescription={account.display_name.length === 0}
|
||||
onEdit={handleNameEdit}
|
||||
>
|
||||
<DisplayNameSimple account={account} />
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.bioTitle}
|
||||
description={messages.bioPlaceholder}
|
||||
showDescription={!account.note_plain}
|
||||
onEdit={handleBioEdit}
|
||||
>
|
||||
<AccountBio accountId={accountId} />
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.customFieldsTitle}
|
||||
description={messages.customFieldsPlaceholder}
|
||||
showDescription
|
||||
/>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.featuredHashtagsTitle}
|
||||
description={messages.featuredHashtagsPlaceholder}
|
||||
showDescription
|
||||
/>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.profileTabTitle}
|
||||
description={messages.profileTabSubtitle}
|
||||
showDescription
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
.columnHeader {
|
||||
:global(.column-header__buttons) {
|
||||
align-items: center;
|
||||
padding-inline-end: 16px;
|
||||
@@ -11,16 +11,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 24px 24px 12px;
|
||||
.profileImage {
|
||||
height: 120px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
overflow: hidden;
|
||||
|
||||
> h1 {
|
||||
flex-grow: 1;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
@container (width >= 500px) {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
> img {
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-top: -64px;
|
||||
margin-left: 18px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
> button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
flex-grow: 1;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Override input styles
|
||||
.inputWrapper .inputText {
|
||||
font-size: 15px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
textarea.inputText {
|
||||
min-height: 82px;
|
||||
height: 100%;
|
||||
|
||||
// 160px is approx the height of the modal header and footer
|
||||
max-height: calc(80vh - 160px);
|
||||
}
|
||||
|
||||
.inputWrapper :global(.emoji-picker-dropdown) {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 8px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
|
||||
:global(.icon-button) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.counterError {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ const InnerNodeModal: FC<{
|
||||
onConfirm={handleSave}
|
||||
updating={state === 'saving'}
|
||||
disabled={!isDirty}
|
||||
closeWhenConfirm={false}
|
||||
noCloseOnConfirm
|
||||
noFocusButton
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -86,11 +86,9 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
if (!timeline) {
|
||||
dispatch(expandTimelineByKey({ key }));
|
||||
}
|
||||
dispatch(expandTimelineByKey({ key }));
|
||||
}
|
||||
}, [accountId, dispatch, key, timeline]);
|
||||
}, [accountId, dispatch, key]);
|
||||
|
||||
const handleLoadMore = useCallback(
|
||||
(maxId: number) => {
|
||||
|
||||
@@ -13,8 +13,9 @@ import { CollectionMenu } from './collection_menu';
|
||||
|
||||
export const CollectionMetaData: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
extended?: boolean;
|
||||
className?: string;
|
||||
}> = ({ collection, className }) => {
|
||||
}> = ({ collection, extended, className }) => {
|
||||
return (
|
||||
<ul className={classNames(classes.metaList, className)}>
|
||||
<FormattedMessage
|
||||
@@ -23,6 +24,30 @@ export const CollectionMetaData: React.FC<{
|
||||
values={{ count: collection.item_count }}
|
||||
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
|
||||
id='collections.last_updated_at'
|
||||
defaultMessage='Last updated: {date}'
|
||||
|
||||
@@ -55,10 +55,6 @@ export const CollectionMenu: React.FC<{
|
||||
text: intl.formatMessage(editorMessages.editDetails),
|
||||
to: `/collections/${id}/edit/details`,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(editorMessages.editSettings),
|
||||
to: `/collections/${id}/edit/settings`,
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: intl.formatMessage(messages.delete),
|
||||
|
||||
@@ -114,6 +114,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
{description && <p className={classes.description}>{description}</p>}
|
||||
<AuthorNote id={collection.account_id} />
|
||||
<CollectionMetaData
|
||||
extended
|
||||
collection={collection}
|
||||
className={classes.metaData}
|
||||
/>
|
||||
|
||||
@@ -132,7 +132,11 @@ export const CollectionAccounts: React.FC<{
|
||||
accountIds: suggestedAccountIds,
|
||||
isLoading: isLoadingSuggestions,
|
||||
searchAccounts,
|
||||
} = useSearchAccounts();
|
||||
} = useSearchAccounts({
|
||||
filterResults: (account) =>
|
||||
// Only suggest accounts who allow being featured/recommended
|
||||
account.feature_approval.current_user === 'automatic',
|
||||
});
|
||||
|
||||
const suggestedItems = suggestedAccountIds.map((id) => ({
|
||||
id,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
ApiCreateCollectionPayload,
|
||||
@@ -11,11 +13,17 @@ import type {
|
||||
} from 'flavours/glitch/api_types/collections';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import {
|
||||
CheckboxField,
|
||||
Fieldset,
|
||||
FormStack,
|
||||
RadioButtonField,
|
||||
TextAreaField,
|
||||
} from 'flavours/glitch/components/form_fields';
|
||||
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 type { TempCollectionState } from './state';
|
||||
@@ -30,12 +38,21 @@ export const CollectionDetails: React.FC<{
|
||||
const history = useHistory();
|
||||
const location = useLocation<TempCollectionState>();
|
||||
|
||||
const { id, initialName, initialDescription, initialTopic, initialItemIds } =
|
||||
getCollectionEditorState(collection, location.state);
|
||||
const {
|
||||
id,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialTopic,
|
||||
initialItemIds,
|
||||
initialDiscoverable,
|
||||
initialSensitive,
|
||||
} = getCollectionEditorState(collection, location.state);
|
||||
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [topic, setTopic] = useState(initialTopic);
|
||||
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
|
||||
const [sensitive, setSensitive] = useState(initialSensitive);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(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(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -68,108 +99,208 @@ export const CollectionDetails: React.FC<{
|
||||
name,
|
||||
description,
|
||||
tag_name: topic || null,
|
||||
discoverable,
|
||||
sensitive,
|
||||
};
|
||||
|
||||
void dispatch(updateCollection({ payload })).then(() => {
|
||||
history.push(`/collections/${id}`);
|
||||
history.goBack();
|
||||
});
|
||||
} else {
|
||||
const payload: Partial<ApiCreateCollectionPayload> = {
|
||||
const payload: ApiCreateCollectionPayload = {
|
||||
name,
|
||||
description,
|
||||
tag_name: topic || null,
|
||||
discoverable,
|
||||
sensitive,
|
||||
account_ids: initialItemIds,
|
||||
};
|
||||
if (topic) {
|
||||
payload.tag_name = topic;
|
||||
}
|
||||
|
||||
history.replace('/collections/new', payload);
|
||||
history.push('/collections/new/settings', payload);
|
||||
void dispatch(
|
||||
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 (
|
||||
<FormStack as='form' onSubmit={handleSubmit}>
|
||||
{!id && (
|
||||
<WizardStepHeader
|
||||
step={2}
|
||||
title={
|
||||
<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.create.basic_details_title'
|
||||
defaultMessage='Basic details'
|
||||
id='collections.collection_name'
|
||||
defaultMessage='Name'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<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' />
|
||||
) : (
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.continue'
|
||||
defaultMessage='Continue'
|
||||
id='collections.name_length_hint'
|
||||
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>
|
||||
</FormStack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { CollectionAccounts } from './accounts';
|
||||
import { CollectionDetails } from './details';
|
||||
import { CollectionSettings } from './settings';
|
||||
|
||||
export const messages = defineMessages({
|
||||
create: {
|
||||
@@ -34,20 +33,12 @@ export const messages = defineMessages({
|
||||
},
|
||||
editDetails: {
|
||||
id: 'collections.edit_details',
|
||||
defaultMessage: 'Edit basic details',
|
||||
defaultMessage: 'Edit details',
|
||||
},
|
||||
manageAccounts: {
|
||||
id: 'collections.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) {
|
||||
@@ -62,8 +53,6 @@ function usePageTitle(id: string | undefined) {
|
||||
return messages.manageAccounts;
|
||||
} else if (matchPath(location.pathname, { path: `${path}/details` })) {
|
||||
return messages.editDetails;
|
||||
} else if (matchPath(location.pathname, { path: `${path}/settings` })) {
|
||||
return messages.editSettings;
|
||||
} else {
|
||||
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
|
||||
render={() => <CollectionDetails collection={collection} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${path}/settings`}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
render={() => <CollectionSettings collection={collection} />}
|
||||
/>
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ export const WizardStepHeader: React.FC<{
|
||||
<FormattedMessage
|
||||
id='collections.create.steps'
|
||||
defaultMessage='Step {step}/{total}'
|
||||
values={{ step, total: 3 }}
|
||||
values={{ step, total: 2 }}
|
||||
>
|
||||
{(content) => <p className={classes.step}>{content}</p>}
|
||||
</FormattedMessage>
|
||||
|
||||
@@ -67,6 +67,24 @@ export function emojiToUnicodeHex(emoji: string): string {
|
||||
return codes.join('-');
|
||||
}
|
||||
|
||||
const CHARS_ALLOWED_AROUND_EMOJI =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[>< …\u0009-\u000d\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
|
||||
|
||||
// TODO: Move to picker file when that's being built out.
|
||||
export function insertEmojiAtPosition(
|
||||
text: string,
|
||||
emoji: string,
|
||||
position = text.length,
|
||||
): string {
|
||||
const isShortcode = isCustomEmoji(emoji);
|
||||
const needsSpace =
|
||||
isShortcode &&
|
||||
position > 0 &&
|
||||
!CHARS_ALLOWED_AROUND_EMOJI.test(text[position - 1] ?? '');
|
||||
return `${text.slice(0, position)}${needsSpace ? ' ' : ''}${emoji} ${text.slice(position)}`;
|
||||
}
|
||||
|
||||
function supportsRegExpSets() {
|
||||
return 'unicodeSets' in RegExp.prototype;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useAppDispatch } from 'flavours/glitch/store';
|
||||
export function useSearchAccounts({
|
||||
resetOnInputClear = true,
|
||||
onSettled,
|
||||
filterResults,
|
||||
}: {
|
||||
onSettled?: (value: string) => void;
|
||||
filterResults?: (account: ApiAccountJSON) => boolean;
|
||||
resetOnInputClear?: boolean;
|
||||
} = {}) {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -49,8 +51,9 @@ export function useSearchAccounts({
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
setAccountIds(data.map((a) => a.id));
|
||||
const accounts = filterResults ? data.filter(filterResults) : data;
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
setAccountIds(accounts.map((a) => a.id));
|
||||
setLoadingState('idle');
|
||||
onSettled?.(value);
|
||||
})
|
||||
|
||||
@@ -19,20 +19,23 @@ const messages = defineMessages({
|
||||
export const ConfirmationModal: React.FC<
|
||||
{
|
||||
title: React.ReactNode;
|
||||
titleId?: string;
|
||||
message?: React.ReactNode;
|
||||
confirm: React.ReactNode;
|
||||
cancel?: React.ReactNode;
|
||||
secondary?: React.ReactNode;
|
||||
onSecondary?: () => void;
|
||||
onConfirm: () => void;
|
||||
closeWhenConfirm?: boolean;
|
||||
noCloseOnConfirm?: boolean;
|
||||
extraContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
updating?: boolean;
|
||||
disabled?: boolean;
|
||||
noFocusButton?: boolean;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({
|
||||
title,
|
||||
titleId,
|
||||
message,
|
||||
confirm,
|
||||
cancel,
|
||||
@@ -40,19 +43,20 @@ export const ConfirmationModal: React.FC<
|
||||
onConfirm,
|
||||
secondary,
|
||||
onSecondary,
|
||||
closeWhenConfirm = true,
|
||||
extraContent,
|
||||
children,
|
||||
updating,
|
||||
disabled,
|
||||
noCloseOnConfirm = false,
|
||||
noFocusButton = false,
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (closeWhenConfirm) {
|
||||
if (!noCloseOnConfirm) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
onConfirm();
|
||||
}, [onClose, onConfirm, closeWhenConfirm]);
|
||||
}, [onClose, onConfirm, noCloseOnConfirm]);
|
||||
|
||||
const handleSecondary = useCallback(() => {
|
||||
onClose();
|
||||
@@ -63,10 +67,10 @@ export const ConfirmationModal: React.FC<
|
||||
<div className='modal-root__modal safety-action-modal'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__confirmation'>
|
||||
<h1>{title}</h1>
|
||||
<h1 id={titleId}>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
|
||||
{extraContent}
|
||||
{extraContent ?? children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
export { ConfirmationModal } from './confirmation_modal';
|
||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||
export { ConfirmDeleteListModal } from './delete_list';
|
||||
|
||||
@@ -33,7 +33,7 @@ const messages = defineMessages({
|
||||
/**
|
||||
* [1] Since we only want this modal to have two buttons – "Don't ask again" and
|
||||
* "Got it" – , we have to use the `onClose` handler to handle the "Don't ask again"
|
||||
* functionality. Because of this, we need to set `closeWhenConfirm` to false and
|
||||
* functionality. Because of this, we need to set `noCloseOnConfirm` to true and
|
||||
* close the modal manually.
|
||||
* This prevents the modal from being dismissed permanently when just confirming.
|
||||
*/
|
||||
@@ -65,13 +65,13 @@ export const QuietPostQuoteInfoModal: React.FC<{ status: Status }> = ({
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
closeWhenConfirm={false} // [1]
|
||||
title={intl.formatMessage(messages.title)}
|
||||
message={intl.formatMessage(messages.message)}
|
||||
confirm={intl.formatMessage(messages.got_it)}
|
||||
cancel={intl.formatMessage(messages.dismiss)}
|
||||
onConfirm={confirm}
|
||||
onClose={dismiss}
|
||||
noCloseOnConfirm
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,6 +96,8 @@ export const MODAL_COMPONENTS = {
|
||||
'ANNUAL_REPORT': AnnualReportModal,
|
||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||
'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
||||
'ACCOUNT_EDIT_NAME': () => import('@/flavours/glitch/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })),
|
||||
'ACCOUNT_EDIT_BIO': () => import('@/flavours/glitch/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
||||
@@ -69,6 +69,11 @@ export const accountDefaultValues: AccountShape = {
|
||||
display_name: '',
|
||||
display_name_html: '',
|
||||
emojis: ImmutableList<CustomEmoji>(),
|
||||
feature_approval: {
|
||||
automatic: [],
|
||||
manual: [],
|
||||
current_user: 'missing',
|
||||
},
|
||||
fields: ImmutableList<AccountField>(),
|
||||
group: false,
|
||||
header: '',
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
} from '@/flavours/glitch/api_types/collections';
|
||||
import { me } from '@/flavours/glitch/initial_state';
|
||||
import {
|
||||
createAppSelector,
|
||||
createDataLoadingThunk,
|
||||
@@ -111,6 +112,14 @@ const collectionSlice = createSlice({
|
||||
const { collectionId } = action.meta.arg;
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete state.collections[collectionId];
|
||||
if (me) {
|
||||
let accountCollectionIds = state.accountCollections[me]?.collectionIds;
|
||||
if (accountCollectionIds) {
|
||||
accountCollectionIds = accountCollectionIds.filter(
|
||||
(id) => id !== collectionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,26 @@ export interface ApiAccountRoleJSON {
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ApiFeaturePolicy =
|
||||
| 'public'
|
||||
| 'followers'
|
||||
| 'following'
|
||||
| 'disabled'
|
||||
| 'unsupported_policy';
|
||||
|
||||
type ApiUserFeaturePolicy =
|
||||
| 'automatic'
|
||||
| 'manual'
|
||||
| 'denied'
|
||||
| 'missing'
|
||||
| 'unknown';
|
||||
|
||||
interface ApiFeaturePolicyJSON {
|
||||
automatic: ApiFeaturePolicy[];
|
||||
manual: ApiFeaturePolicy[];
|
||||
current_user: ApiUserFeaturePolicy;
|
||||
}
|
||||
|
||||
// See app/serializers/rest/account_serializer.rb
|
||||
export interface BaseApiAccountJSON {
|
||||
acct: string;
|
||||
@@ -23,6 +43,7 @@ export interface BaseApiAccountJSON {
|
||||
indexable: boolean;
|
||||
display_name: string;
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
feature_approval: ApiFeaturePolicyJSON;
|
||||
fields: ApiAccountFieldJSON[];
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
interface AccountBioProps {
|
||||
className: string;
|
||||
className?: string;
|
||||
accountId: string;
|
||||
showDropdown?: boolean;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ export const WithError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AutoSize: Story = {
|
||||
args: {
|
||||
autoSize: true,
|
||||
defaultValue: 'This textarea will grow as you type more lines.',
|
||||
},
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render(args) {
|
||||
return <TextArea {...args} />;
|
||||
|
||||
@@ -3,12 +3,16 @@ import { forwardRef, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
import TextAreaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './text_input.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
|
||||
type TextAreaProps =
|
||||
| ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
|
||||
| ({ autoSize: true } & TextareaAutosizeProps);
|
||||
|
||||
/**
|
||||
* A simple form field for multi-line text.
|
||||
@@ -17,45 +21,56 @@ interface Props
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
|
||||
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
export const TextAreaField = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextAreaProps & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
));
|
||||
|
||||
TextAreaField.displayName = 'TextAreaField';
|
||||
|
||||
export const TextArea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
ComponentPropsWithoutRef<'textarea'>
|
||||
>(({ className, onKeyDown, ...otherProps }, ref) => {
|
||||
const handleSubmitHotkey = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
const targetForm = e.currentTarget.form;
|
||||
targetForm?.requestSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown],
|
||||
);
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
({ className, onKeyDown, autoSize, ...otherProps }, ref) => {
|
||||
const handleSubmitHotkey = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
const targetForm = e.currentTarget.form;
|
||||
targetForm?.requestSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown],
|
||||
);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...otherProps}
|
||||
onKeyDown={handleSubmitHotkey}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (autoSize) {
|
||||
return (
|
||||
<TextAreaAutosize
|
||||
{...(otherProps as TextareaAutosizeProps)}
|
||||
onKeyDown={handleSubmitHotkey}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...otherProps}
|
||||
onKeyDown={handleSubmitHotkey}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextArea } from '@/mastodon/components/form_fields';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.bio_modal.add_title',
|
||||
defaultMessage: 'Add bio',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.bio_modal.edit_title',
|
||||
defaultMessage: 'Edit bio',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_BIO_LENGTH = 500;
|
||||
|
||||
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const [newBio, setNewBio] = useState(account?.note_plain ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
setNewBio(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePickEmoji = useCallback((emoji: string) => {
|
||||
setNewBio((prev) => {
|
||||
const position = textAreaRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!account) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(
|
||||
account.note_plain ? messages.editTitle : messages.addTitle,
|
||||
)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={onClose} // To be implemented
|
||||
onClose={onClose}
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextArea
|
||||
value={newBio}
|
||||
ref={textAreaRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
autoSize
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newBio.length}
|
||||
maxLength={MAX_BIO_LENGTH}
|
||||
id={counterId}
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const CharCounter = polymorphicForwardRef<
|
||||
'p',
|
||||
{ currentLength: number; maxLength: number }
|
||||
>(({ currentLength, maxLength, as: Component = 'p' }, ref) => (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
classes.counter,
|
||||
currentLength > maxLength && classes.counterError,
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.char_counter'
|
||||
defaultMessage='{currentLength}/{maxLength} characters'
|
||||
values={{ currentLength, maxLength }}
|
||||
/>
|
||||
</Component>
|
||||
));
|
||||
CharCounter.displayName = 'CharCounter';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { isPlainObject } from '@reduxjs/toolkit';
|
||||
|
||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
export const EmojiPicker: FC<{ onPick: (emoji: string) => void }> = ({
|
||||
onPick,
|
||||
}) => {
|
||||
const handlePick = useCallback(
|
||||
(emoji: unknown) => {
|
||||
if (isPlainObject(emoji)) {
|
||||
if ('native' in emoji && typeof emoji.native === 'string') {
|
||||
onPick(emoji.native);
|
||||
} else if (
|
||||
'shortcode' in emoji &&
|
||||
typeof emoji.shortcode === 'string'
|
||||
) {
|
||||
onPick(`:${emoji.shortcode}:`);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onPick],
|
||||
);
|
||||
return <EmojiPickerDropdown onPickEmoji={handlePick} />;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextInput } from '@/mastodon/components/form_fields';
|
||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.name_modal.add_title',
|
||||
defaultMessage: 'Add display name',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.name_modal.edit_title',
|
||||
defaultMessage: 'Edit display name',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_NAME_LENGTH = 30;
|
||||
|
||||
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const [newName, setNewName] = useState(account?.display_name ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
setNewName(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePickEmoji = useCallback((emoji: string) => {
|
||||
setNewName((prev) => {
|
||||
const position = inputRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.editTitle)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={onClose} // To be implemented
|
||||
onClose={onClose}
|
||||
noCloseOnConfirm
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextInput
|
||||
value={newName}
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newName.length}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
id={counterId}
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
const buttonMessage = defineMessage({
|
||||
id: 'account_edit.section_edit_button',
|
||||
defaultMessage: 'Edit',
|
||||
});
|
||||
|
||||
interface AccountEditSectionProps {
|
||||
title: MessageDescriptor;
|
||||
description?: MessageDescriptor;
|
||||
showDescription?: boolean;
|
||||
onEdit?: () => void;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
extraButtons?: ReactNode;
|
||||
}
|
||||
|
||||
export const AccountEditSection: FC<AccountEditSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
showDescription,
|
||||
onEdit,
|
||||
children,
|
||||
className,
|
||||
extraButtons,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<section className={classNames(className, classes.section)}>
|
||||
<header className={classes.sectionHeader}>
|
||||
<h3 className={classes.sectionTitle}>
|
||||
<FormattedMessage {...title} />
|
||||
</h3>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
icon='pencil'
|
||||
iconComponent={EditIcon}
|
||||
onClick={onEdit}
|
||||
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
|
||||
/>
|
||||
)}
|
||||
{extraButtons}
|
||||
</header>
|
||||
{showDescription && (
|
||||
<p className={classes.sectionSubtitle}>
|
||||
<FormattedMessage {...description} />
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ModalType } from '@/mastodon/actions/modal';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnHeader } from '@/mastodon/components/column_header';
|
||||
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { AccountEditSection } from './components/section';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
displayNameTitle: {
|
||||
id: 'account_edit.display_name.title',
|
||||
defaultMessage: 'Display name',
|
||||
},
|
||||
displayNamePlaceholder: {
|
||||
id: 'account_edit.display_name.placeholder',
|
||||
defaultMessage:
|
||||
'Your display name is how your name appears on your profile and in timelines.',
|
||||
},
|
||||
bioTitle: {
|
||||
id: 'account_edit.bio.title',
|
||||
defaultMessage: 'Bio',
|
||||
},
|
||||
bioPlaceholder: {
|
||||
id: 'account_edit.bio.placeholder',
|
||||
defaultMessage: 'Add a short introduction to help others identify you.',
|
||||
},
|
||||
customFieldsTitle: {
|
||||
id: 'account_edit.custom_fields.title',
|
||||
defaultMessage: 'Custom fields',
|
||||
},
|
||||
customFieldsPlaceholder: {
|
||||
id: 'account_edit.custom_fields.placeholder',
|
||||
defaultMessage:
|
||||
'Add your pronouns, external links, or anything else you’d like to share.',
|
||||
},
|
||||
featuredHashtagsTitle: {
|
||||
id: 'account_edit.featured_hashtags.title',
|
||||
defaultMessage: 'Featured hashtags',
|
||||
},
|
||||
featuredHashtagsPlaceholder: {
|
||||
id: 'account_edit.featured_hashtags.placeholder',
|
||||
defaultMessage:
|
||||
'Help others identify, and have quick access to, your favorite topics.',
|
||||
},
|
||||
profileTabTitle: {
|
||||
id: 'account_edit.profile_tab.title',
|
||||
defaultMessage: 'Profile tab settings',
|
||||
},
|
||||
profileTabSubtitle: {
|
||||
id: 'account_edit.profile_tab.subtitle',
|
||||
defaultMessage: 'Customize the tabs on your profile and what they display.',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleOpenModal = useCallback(
|
||||
(type: ModalType, props?: Record<string, unknown>) => {
|
||||
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const handleNameEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_NAME');
|
||||
}, [handleOpenModal]);
|
||||
const handleBioEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||
}, [handleOpenModal]);
|
||||
|
||||
if (!accountId) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
@@ -30,6 +99,8 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const headerSrc = autoPlayGif ? account.header : account.header_static;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<ColumnHeader
|
||||
@@ -37,7 +108,7 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
id: 'account_edit.column_title',
|
||||
defaultMessage: 'Edit Profile',
|
||||
})}
|
||||
className={classes.header}
|
||||
className={classes.columnHeader}
|
||||
showBackButton
|
||||
extraButton={
|
||||
<Link to={`/@${account.acct}`} className='button'>
|
||||
@@ -48,6 +119,48 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<header>
|
||||
<div className={classes.profileImage}>
|
||||
{headerSrc && <img src={headerSrc} alt='' />}
|
||||
</div>
|
||||
<Avatar account={account} size={80} className={classes.avatar} />
|
||||
</header>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.displayNameTitle}
|
||||
description={messages.displayNamePlaceholder}
|
||||
showDescription={account.display_name.length === 0}
|
||||
onEdit={handleNameEdit}
|
||||
>
|
||||
<DisplayNameSimple account={account} />
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.bioTitle}
|
||||
description={messages.bioPlaceholder}
|
||||
showDescription={!account.note_plain}
|
||||
onEdit={handleBioEdit}
|
||||
>
|
||||
<AccountBio accountId={accountId} />
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.customFieldsTitle}
|
||||
description={messages.customFieldsPlaceholder}
|
||||
showDescription
|
||||
/>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.featuredHashtagsTitle}
|
||||
description={messages.featuredHashtagsPlaceholder}
|
||||
showDescription
|
||||
/>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.profileTabTitle}
|
||||
description={messages.profileTabSubtitle}
|
||||
showDescription
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
.columnHeader {
|
||||
:global(.column-header__buttons) {
|
||||
align-items: center;
|
||||
padding-inline-end: 16px;
|
||||
@@ -11,16 +11,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 24px 24px 12px;
|
||||
.profileImage {
|
||||
height: 120px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
overflow: hidden;
|
||||
|
||||
> h1 {
|
||||
flex-grow: 1;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
@container (width >= 500px) {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
> img {
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-top: -64px;
|
||||
margin-left: 18px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
> button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
flex-grow: 1;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Override input styles
|
||||
.inputWrapper .inputText {
|
||||
font-size: 15px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
textarea.inputText {
|
||||
min-height: 82px;
|
||||
height: 100%;
|
||||
|
||||
// 160px is approx the height of the modal header and footer
|
||||
max-height: calc(80vh - 160px);
|
||||
}
|
||||
|
||||
.inputWrapper :global(.emoji-picker-dropdown) {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 8px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
|
||||
:global(.icon-button) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.counterError {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ const InnerNodeModal: FC<{
|
||||
onConfirm={handleSave}
|
||||
updating={state === 'saving'}
|
||||
disabled={!isDirty}
|
||||
closeWhenConfirm={false}
|
||||
noCloseOnConfirm
|
||||
noFocusButton
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -86,11 +86,9 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
if (!timeline) {
|
||||
dispatch(expandTimelineByKey({ key }));
|
||||
}
|
||||
dispatch(expandTimelineByKey({ key }));
|
||||
}
|
||||
}, [accountId, dispatch, key, timeline]);
|
||||
}, [accountId, dispatch, key]);
|
||||
|
||||
const handleLoadMore = useCallback(
|
||||
(maxId: number) => {
|
||||
|
||||
@@ -13,8 +13,9 @@ import { CollectionMenu } from './collection_menu';
|
||||
|
||||
export const CollectionMetaData: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
extended?: boolean;
|
||||
className?: string;
|
||||
}> = ({ collection, className }) => {
|
||||
}> = ({ collection, extended, className }) => {
|
||||
return (
|
||||
<ul className={classNames(classes.metaList, className)}>
|
||||
<FormattedMessage
|
||||
@@ -23,6 +24,30 @@ export const CollectionMetaData: React.FC<{
|
||||
values={{ count: collection.item_count }}
|
||||
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
|
||||
id='collections.last_updated_at'
|
||||
defaultMessage='Last updated: {date}'
|
||||
|
||||
@@ -55,10 +55,6 @@ export const CollectionMenu: React.FC<{
|
||||
text: intl.formatMessage(editorMessages.editDetails),
|
||||
to: `/collections/${id}/edit/details`,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(editorMessages.editSettings),
|
||||
to: `/collections/${id}/edit/settings`,
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: intl.formatMessage(messages.delete),
|
||||
|
||||
@@ -114,6 +114,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
{description && <p className={classes.description}>{description}</p>}
|
||||
<AuthorNote id={collection.account_id} />
|
||||
<CollectionMetaData
|
||||
extended
|
||||
collection={collection}
|
||||
className={classes.metaData}
|
||||
/>
|
||||
|
||||
@@ -129,7 +129,11 @@ export const CollectionAccounts: React.FC<{
|
||||
accountIds: suggestedAccountIds,
|
||||
isLoading: isLoadingSuggestions,
|
||||
searchAccounts,
|
||||
} = useSearchAccounts();
|
||||
} = useSearchAccounts({
|
||||
filterResults: (account) =>
|
||||
// Only suggest accounts who allow being featured/recommended
|
||||
account.feature_approval.current_user === 'automatic',
|
||||
});
|
||||
|
||||
const suggestedItems = suggestedAccountIds.map((id) => ({
|
||||
id,
|
||||
|
||||
@@ -4,15 +4,26 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
} from 'mastodon/api_types/collections';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { FormStack, TextAreaField } from 'mastodon/components/form_fields';
|
||||
import {
|
||||
CheckboxField,
|
||||
Fieldset,
|
||||
FormStack,
|
||||
RadioButtonField,
|
||||
TextAreaField,
|
||||
} from 'mastodon/components/form_fields';
|
||||
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
|
||||
import { updateCollection } from 'mastodon/reducers/slices/collections';
|
||||
import {
|
||||
createCollection,
|
||||
updateCollection,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import type { TempCollectionState } from './state';
|
||||
@@ -27,12 +38,21 @@ export const CollectionDetails: React.FC<{
|
||||
const history = useHistory();
|
||||
const location = useLocation<TempCollectionState>();
|
||||
|
||||
const { id, initialName, initialDescription, initialTopic, initialItemIds } =
|
||||
getCollectionEditorState(collection, location.state);
|
||||
const {
|
||||
id,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialTopic,
|
||||
initialItemIds,
|
||||
initialDiscoverable,
|
||||
initialSensitive,
|
||||
} = getCollectionEditorState(collection, location.state);
|
||||
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [topic, setTopic] = useState(initialTopic);
|
||||
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
|
||||
const [sensitive, setSensitive] = useState(initialSensitive);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -55,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(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -65,108 +99,208 @@ export const CollectionDetails: React.FC<{
|
||||
name,
|
||||
description,
|
||||
tag_name: topic || null,
|
||||
discoverable,
|
||||
sensitive,
|
||||
};
|
||||
|
||||
void dispatch(updateCollection({ payload })).then(() => {
|
||||
history.push(`/collections/${id}`);
|
||||
history.goBack();
|
||||
});
|
||||
} else {
|
||||
const payload: Partial<ApiCreateCollectionPayload> = {
|
||||
const payload: ApiCreateCollectionPayload = {
|
||||
name,
|
||||
description,
|
||||
tag_name: topic || null,
|
||||
discoverable,
|
||||
sensitive,
|
||||
account_ids: initialItemIds,
|
||||
};
|
||||
if (topic) {
|
||||
payload.tag_name = topic;
|
||||
}
|
||||
|
||||
history.replace('/collections/new', payload);
|
||||
history.push('/collections/new/settings', payload);
|
||||
void dispatch(
|
||||
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 (
|
||||
<FormStack as='form' onSubmit={handleSubmit}>
|
||||
{!id && (
|
||||
<WizardStepHeader
|
||||
step={2}
|
||||
title={
|
||||
<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.create.basic_details_title'
|
||||
defaultMessage='Basic details'
|
||||
id='collections.collection_name'
|
||||
defaultMessage='Name'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<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' />
|
||||
) : (
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.continue'
|
||||
defaultMessage='Continue'
|
||||
id='collections.name_length_hint'
|
||||
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>
|
||||
</FormStack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { CollectionAccounts } from './accounts';
|
||||
import { CollectionDetails } from './details';
|
||||
import { CollectionSettings } from './settings';
|
||||
|
||||
export const messages = defineMessages({
|
||||
create: {
|
||||
@@ -34,20 +33,12 @@ export const messages = defineMessages({
|
||||
},
|
||||
editDetails: {
|
||||
id: 'collections.edit_details',
|
||||
defaultMessage: 'Edit basic details',
|
||||
defaultMessage: 'Edit details',
|
||||
},
|
||||
manageAccounts: {
|
||||
id: 'collections.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) {
|
||||
@@ -62,8 +53,6 @@ function usePageTitle(id: string | undefined) {
|
||||
return messages.manageAccounts;
|
||||
} else if (matchPath(location.pathname, { path: `${path}/details` })) {
|
||||
return messages.editDetails;
|
||||
} else if (matchPath(location.pathname, { path: `${path}/settings` })) {
|
||||
return messages.editSettings;
|
||||
} else {
|
||||
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
|
||||
render={() => <CollectionDetails collection={collection} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${path}/settings`}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
render={() => <CollectionSettings collection={collection} />}
|
||||
/>
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 'mastodon/api_types/collections';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import {
|
||||
Fieldset,
|
||||
FormStack,
|
||||
CheckboxField,
|
||||
RadioButtonField,
|
||||
} from 'mastodon/components/form_fields';
|
||||
import {
|
||||
createCollection,
|
||||
updateCollection,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch } from 'mastodon/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>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ export const WizardStepHeader: React.FC<{
|
||||
<FormattedMessage
|
||||
id='collections.create.steps'
|
||||
defaultMessage='Step {step}/{total}'
|
||||
values={{ step, total: 3 }}
|
||||
values={{ step, total: 2 }}
|
||||
>
|
||||
{(content) => <p className={classes.step}>{content}</p>}
|
||||
</FormattedMessage>
|
||||
|
||||
@@ -67,6 +67,24 @@ export function emojiToUnicodeHex(emoji: string): string {
|
||||
return codes.join('-');
|
||||
}
|
||||
|
||||
const CHARS_ALLOWED_AROUND_EMOJI =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[>< …\u0009-\u000d\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
|
||||
|
||||
// TODO: Move to picker file when that's being built out.
|
||||
export function insertEmojiAtPosition(
|
||||
text: string,
|
||||
emoji: string,
|
||||
position = text.length,
|
||||
): string {
|
||||
const isShortcode = isCustomEmoji(emoji);
|
||||
const needsSpace =
|
||||
isShortcode &&
|
||||
position > 0 &&
|
||||
!CHARS_ALLOWED_AROUND_EMOJI.test(text[position - 1] ?? '');
|
||||
return `${text.slice(0, position)}${needsSpace ? ' ' : ''}${emoji} ${text.slice(position)}`;
|
||||
}
|
||||
|
||||
function supportsRegExpSets() {
|
||||
return 'unicodeSets' in RegExp.prototype;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useAppDispatch } from 'mastodon/store';
|
||||
export function useSearchAccounts({
|
||||
resetOnInputClear = true,
|
||||
onSettled,
|
||||
filterResults,
|
||||
}: {
|
||||
onSettled?: (value: string) => void;
|
||||
filterResults?: (account: ApiAccountJSON) => boolean;
|
||||
resetOnInputClear?: boolean;
|
||||
} = {}) {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -49,8 +51,9 @@ export function useSearchAccounts({
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
setAccountIds(data.map((a) => a.id));
|
||||
const accounts = filterResults ? data.filter(filterResults) : data;
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
setAccountIds(accounts.map((a) => a.id));
|
||||
setLoadingState('idle');
|
||||
onSettled?.(value);
|
||||
})
|
||||
|
||||
@@ -11,20 +11,23 @@ export interface BaseConfirmationModalProps {
|
||||
export const ConfirmationModal: React.FC<
|
||||
{
|
||||
title: React.ReactNode;
|
||||
titleId?: string;
|
||||
message?: React.ReactNode;
|
||||
confirm: React.ReactNode;
|
||||
cancel?: React.ReactNode;
|
||||
secondary?: React.ReactNode;
|
||||
onSecondary?: () => void;
|
||||
onConfirm: () => void;
|
||||
closeWhenConfirm?: boolean;
|
||||
noCloseOnConfirm?: boolean;
|
||||
extraContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
updating?: boolean;
|
||||
disabled?: boolean;
|
||||
noFocusButton?: boolean;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({
|
||||
title,
|
||||
titleId,
|
||||
message,
|
||||
confirm,
|
||||
cancel,
|
||||
@@ -32,19 +35,20 @@ export const ConfirmationModal: React.FC<
|
||||
onConfirm,
|
||||
secondary,
|
||||
onSecondary,
|
||||
closeWhenConfirm = true,
|
||||
extraContent,
|
||||
children,
|
||||
updating,
|
||||
disabled,
|
||||
noCloseOnConfirm = false,
|
||||
noFocusButton = false,
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (closeWhenConfirm) {
|
||||
if (!noCloseOnConfirm) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
onConfirm();
|
||||
}, [onClose, onConfirm, closeWhenConfirm]);
|
||||
}, [onClose, onConfirm, noCloseOnConfirm]);
|
||||
|
||||
const handleSecondary = useCallback(() => {
|
||||
onClose();
|
||||
@@ -55,10 +59,10 @@ export const ConfirmationModal: React.FC<
|
||||
<div className='modal-root__modal safety-action-modal'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__confirmation'>
|
||||
<h1>{title}</h1>
|
||||
<h1 id={titleId}>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
|
||||
{extraContent}
|
||||
{extraContent ?? children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
export { ConfirmationModal } from './confirmation_modal';
|
||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||
export { ConfirmDeleteListModal } from './delete_list';
|
||||
|
||||
@@ -33,7 +33,7 @@ const messages = defineMessages({
|
||||
/**
|
||||
* [1] Since we only want this modal to have two buttons – "Don't ask again" and
|
||||
* "Got it" – , we have to use the `onClose` handler to handle the "Don't ask again"
|
||||
* functionality. Because of this, we need to set `closeWhenConfirm` to false and
|
||||
* functionality. Because of this, we need to set `noCloseOnConfirm` to true and
|
||||
* close the modal manually.
|
||||
* This prevents the modal from being dismissed permanently when just confirming.
|
||||
*/
|
||||
@@ -65,13 +65,13 @@ export const QuietPostQuoteInfoModal: React.FC<{ status: Status }> = ({
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
closeWhenConfirm={false} // [1]
|
||||
title={intl.formatMessage(messages.title)}
|
||||
message={intl.formatMessage(messages.message)}
|
||||
confirm={intl.formatMessage(messages.got_it)}
|
||||
cancel={intl.formatMessage(messages.dismiss)}
|
||||
onConfirm={confirm}
|
||||
onClose={dismiss}
|
||||
noCloseOnConfirm
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,6 +88,8 @@ export const MODAL_COMPONENTS = {
|
||||
'ANNUAL_REPORT': AnnualReportModal,
|
||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
||||
'ACCOUNT_EDIT_NAME': () => import('@/mastodon/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })),
|
||||
'ACCOUNT_EDIT_BIO': () => import('@/mastodon/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Стварыць калекцыю",
|
||||
"collections.delete_collection": "Выдаліць калекцыю",
|
||||
"collections.description_length_hint": "Максімум 100 сімвалаў",
|
||||
"collections.detail.accounts_heading": "Уліковыя запісы",
|
||||
"collections.detail.curated_by_author": "Курыруе {author}",
|
||||
"collections.detail.curated_by_you": "Курыруеце Вы",
|
||||
"collections.detail.loading": "Загружаецца калекцыя…",
|
||||
"collections.detail.share": "Падзяліцца гэтай калекцыяй",
|
||||
"collections.edit_details": "Змяніць асноўныя звесткі",
|
||||
"collections.edit_settings": "Змяніць налады",
|
||||
"collections.error_loading_collections": "Адбылася памылка падчас загрузкі Вашых калекцый.",
|
||||
|
||||
@@ -13,11 +13,18 @@
|
||||
"about.not_available": "Tato informace nebyla zpřístupněna na tomto serveru.",
|
||||
"about.powered_by": "Decentralizovaná sociální média poháněná {mastodon}",
|
||||
"about.rules": "Pravidla serveru",
|
||||
"account.about": "O účtu",
|
||||
"account.account_note_header": "Osobní poznámka",
|
||||
"account.activity": "Aktivita",
|
||||
"account.add_note": "Přidat vlastní poznámku",
|
||||
"account.add_or_remove_from_list": "Přidat nebo odstranit ze seznamů",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Zablokovaný",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.domain_blocked": "Zablokovaná doména",
|
||||
"account.badges.group": "Skupina",
|
||||
"account.badges.muted": "Ztišeno",
|
||||
"account.badges.muted_until": "Ztišen do {until}",
|
||||
"account.block": "Blokovat @{name}",
|
||||
"account.block_domain": "Blokovat doménu {domain}",
|
||||
"account.block_short": "Zablokovat",
|
||||
@@ -28,6 +35,7 @@
|
||||
"account.direct": "Soukromě zmínit @{name}",
|
||||
"account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek",
|
||||
"account.domain_blocking": "Blokované domény",
|
||||
"account.edit_note": "Upravit vlastní poznámku",
|
||||
"account.edit_profile": "Upravit profil",
|
||||
"account.edit_profile_short": "Upravit",
|
||||
"account.enable_notifications": "Oznamovat mi příspěvky @{name}",
|
||||
@@ -40,6 +48,12 @@
|
||||
"account.featured.hashtags": "Hashtagy",
|
||||
"account.featured_tags.last_status_at": "Poslední příspěvek {date}",
|
||||
"account.featured_tags.last_status_never": "Žádné příspěvky",
|
||||
"account.filters.all": "Veškerá aktivita",
|
||||
"account.filters.boosts_toggle": "Zobrazit boosty",
|
||||
"account.filters.posts_boosts": "Příspěvky a boosty",
|
||||
"account.filters.posts_only": "Příspěvky",
|
||||
"account.filters.posts_replies": "Příspěvky a odpovědi",
|
||||
"account.filters.replies_toggle": "Zobrazit odpovědi",
|
||||
"account.follow": "Sledovat",
|
||||
"account.follow_back": "Také sledovat",
|
||||
"account.follow_back_short": "Také sledovat",
|
||||
@@ -65,6 +79,24 @@
|
||||
"account.locked_info": "Stav soukromí tohoto účtu je nastaven na zamčeno. Jeho vlastník ručně posuzuje, kdo ho může sledovat.",
|
||||
"account.media": "Média",
|
||||
"account.mention": "Zmínit @{name}",
|
||||
"account.menu.add_to_list": "Přidat do seznamu…",
|
||||
"account.menu.block": "Blokovat účet",
|
||||
"account.menu.block_domain": "Blokovat {domain}",
|
||||
"account.menu.copied": "Odkaz účtu byl zkopírován do schránky",
|
||||
"account.menu.copy": "Zkopírovat odkaz",
|
||||
"account.menu.direct": "Soukromě zmínit",
|
||||
"account.menu.hide_reblogs": "Skrýt boosty na časové ose",
|
||||
"account.menu.mention": "Zmínit",
|
||||
"account.menu.mute": "Ztlumit účet",
|
||||
"account.menu.note.description": "Viditelné pouze pro vás",
|
||||
"account.menu.open_original_page": "Zobrazit na {domain}",
|
||||
"account.menu.remove_follower": "Odstranit sledujícího",
|
||||
"account.menu.report": "Nahlásit účet",
|
||||
"account.menu.share": "Sdílet…",
|
||||
"account.menu.show_reblogs": "Zobrazit boosty na časové ose",
|
||||
"account.menu.unblock": "Odblokovat účet",
|
||||
"account.menu.unblock_domain": "Odblokovat {domain}",
|
||||
"account.menu.unmute": "Zrušit ztlumení účtu",
|
||||
"account.moved_to": "Uživatel {name} uvedl, že jeho nový účet je nyní:",
|
||||
"account.mute": "Skrýt @{name}",
|
||||
"account.mute_notifications_short": "Ztlumit upozornění",
|
||||
@@ -72,7 +104,14 @@
|
||||
"account.muted": "Skrytý",
|
||||
"account.muting": "Ztlumení",
|
||||
"account.mutual": "Sledujete se navzájem",
|
||||
"account.name.help.domain": "{domain} je server, který hostuje profily a příspěvky uživatelů.",
|
||||
"account.name.help.domain_self": "{domain} je váš server, který hostuje váš profil a příspěvky.",
|
||||
"account.no_bio": "Nebyl poskytnut žádný popis.",
|
||||
"account.node_modal.field_label": "Vlastní poznámka",
|
||||
"account.node_modal.save": "Uložit",
|
||||
"account.node_modal.title": "Přidat vlastní poznámku",
|
||||
"account.note.edit_button": "Upravit",
|
||||
"account.note.title": "Vlastní poznámka (viditelná pouze pro vás)",
|
||||
"account.open_original_page": "Otevřít původní stránku",
|
||||
"account.posts": "Příspěvky",
|
||||
"account.posts_with_replies": "Příspěvky a odpovědi",
|
||||
@@ -83,6 +122,8 @@
|
||||
"account.share": "Sdílet profil @{name}",
|
||||
"account.show_reblogs": "Zobrazit boosty od @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}",
|
||||
"account.timeline.pinned": "Připnuto",
|
||||
"account.timeline.pinned.view_all": "Zobrazit všechny připnuté příspěvky",
|
||||
"account.unblock": "Odblokovat @{name}",
|
||||
"account.unblock_domain": "Odblokovat doménu {domain}",
|
||||
"account.unblock_domain_short": "Odblokovat",
|
||||
@@ -92,6 +133,8 @@
|
||||
"account.unmute": "Zrušit skrytí @{name}",
|
||||
"account.unmute_notifications_short": "Zrušit ztlumení oznámení",
|
||||
"account.unmute_short": "Zrušit skrytí",
|
||||
"account_edit.column_button": "Hotovo",
|
||||
"account_edit.column_title": "Upravit profil",
|
||||
"account_note.placeholder": "Klikněte pro přidání poznámky",
|
||||
"admin.dashboard.daily_retention": "Míra udržení uživatelů podle dne po registraci",
|
||||
"admin.dashboard.monthly_retention": "Míra udržení uživatelů podle měsíce po registraci",
|
||||
@@ -186,6 +229,7 @@
|
||||
"bundle_modal_error.close": "Zavřít",
|
||||
"bundle_modal_error.message": "Něco se pokazilo při načítání této obrazovky.",
|
||||
"bundle_modal_error.retry": "Zkusit znovu",
|
||||
"callout.dismiss": "Zamítnout",
|
||||
"carousel.current": "<sr>Snímek</sr> {current, number}/{max, number}",
|
||||
"carousel.slide": "Snímek {current, number} z {max, number}",
|
||||
"closed_registrations.other_server_instructions": "Protože Mastodon je decentralizovaný, můžete si vytvořit účet na jiném serveru a přesto komunikovat s tímto serverem.",
|
||||
@@ -193,12 +237,24 @@
|
||||
"closed_registrations_modal.find_another_server": "Najít jiný server",
|
||||
"closed_registrations_modal.preamble": "Mastodon je decentralizovaný, takže bez ohledu na to, kde vytvoříte svůj účet, budete moci sledovat a komunikovat s kýmkoli na tomto serveru. Můžete ho dokonce hostovat!",
|
||||
"closed_registrations_modal.title": "Registrace na Mastodon",
|
||||
"collections.continue": "Pokračovat",
|
||||
"collections.mark_as_sensitive_hint": "Skryje popis kolekce a účty za varováním obsahu. Název kolekce bude stále viditelný.",
|
||||
"collections.name_length_hint": "Max. 100 znaků",
|
||||
"collections.new_collection": "Nová sbírka",
|
||||
"collections.no_collections_yet": "Ještě nemáte žádné sbírky.",
|
||||
"collections.remove_account": "Odstranit tento účet",
|
||||
"collections.search_accounts_label": "Hledat účty pro přidání…",
|
||||
"collections.search_accounts_max_reached": "Přidali jste maximální počet účtů",
|
||||
"collections.topic_hint": "Přidat štítek, který pomůže ostatním pochopit hlavní téma této kolekce.",
|
||||
"collections.view_collection": "Zobrazit sbírku",
|
||||
"collections.visibility_public": "Veřejné",
|
||||
"collections.visibility_public_hint": "Objevitelné ve výsledcích vyhledávání a dalších místech, kde se objevují doporučení.",
|
||||
"collections.visibility_title": "Viditelnost",
|
||||
"collections.visibility_unlisted": "Neveřejný",
|
||||
"column.about": "O aplikaci",
|
||||
"column.blocks": "Blokovaní uživatelé",
|
||||
"column.bookmarks": "Záložky",
|
||||
"column.collections": "Mé sbírky",
|
||||
"column.community": "Místní časová osa",
|
||||
"column.create_list": "Vytvořit seznam",
|
||||
"column.direct": "Soukromé zmínky",
|
||||
@@ -225,6 +281,10 @@
|
||||
"column_header.show_settings": "Zobrazit nastavení",
|
||||
"column_header.unpin": "Odepnout",
|
||||
"column_search.cancel": "Zrušit",
|
||||
"combobox.close_results": "Zavřít výsledky",
|
||||
"combobox.loading": "Načítání",
|
||||
"combobox.no_results_found": "Žádné výsledky pro toto vyhledávání",
|
||||
"combobox.open_results": "Otevřít výsledky",
|
||||
"community.column_settings.local_only": "Pouze místní",
|
||||
"community.column_settings.media_only": "Pouze média",
|
||||
"community.column_settings.remote_only": "Pouze vzdálené",
|
||||
@@ -258,6 +318,7 @@
|
||||
"confirmations.delete.confirm": "Smazat",
|
||||
"confirmations.delete.message": "Opravdu chcete smazat tento příspěvek?",
|
||||
"confirmations.delete.title": "Smazat příspěvek?",
|
||||
"confirmations.delete_collection.title": "Smazat „{name}“?",
|
||||
"confirmations.delete_list.confirm": "Smazat",
|
||||
"confirmations.delete_list.message": "Opravdu chcete tento seznam navždy smazat?",
|
||||
"confirmations.delete_list.title": "Smazat seznam?",
|
||||
@@ -364,6 +425,8 @@
|
||||
"emoji_button.search_results": "Výsledky hledání",
|
||||
"emoji_button.symbols": "Symboly",
|
||||
"emoji_button.travel": "Cestování a místa",
|
||||
"empty_column.account_about.me": "Zatím jste o sobě nepřidali žádné informace.",
|
||||
"empty_column.account_about.other": "{acct} zatím o sobě nepřidali žádné informace.",
|
||||
"empty_column.account_featured.me": "Zatím jste nic nezvýraznili. Věděli jste, že na svém profilu můžete zvýraznit hashtagy, které používáte nejvíce, a dokonce účty vašich přátel?",
|
||||
"empty_column.account_featured.other": "{acct} zatím nic nezvýraznili. Věděli jste, že na svém profilu můžete zvýraznit hashtagy, které používáte nejvíce, a dokonce účty vašich přátel?",
|
||||
"empty_column.account_featured_other.unknown": "Tento účet zatím nemá nic zvýrazněného.",
|
||||
@@ -389,6 +452,7 @@
|
||||
"empty_column.notification_requests": "Vyčištěno! Nic tu není. Jakmile obdržíš nové notifikace, objeví se zde podle tvého nastavení.",
|
||||
"empty_column.notifications": "Zatím nemáte žádná oznámení. Až s vámi někdo bude interagovat, uvidíte to zde.",
|
||||
"empty_column.public": "Tady nic není! Napište něco veřejně, nebo začněte ručně sledovat uživatele z jiných serverů, aby tu něco přibylo",
|
||||
"empty_state.no_results": "Žádné výsledky",
|
||||
"error.no_hashtag_feed_access": "Zaregistrujte se nebo se přihlaste k zobrazení a sledování tohoto hashtagu.",
|
||||
"error.unexpected_crash.explanation": "Kvůli chybě v našem kódu nebo problému s kompatibilitou prohlížeče nemohla být tato stránka správně zobrazena.",
|
||||
"error.unexpected_crash.explanation_addons": "Tuto stránku nelze správně zobrazit. Takovou chybu obvykle způsobuje doplněk prohlížeče nebo nástroje pro automatický překlad.",
|
||||
@@ -404,6 +468,7 @@
|
||||
"featured_carousel.current": "<sr>Příspěvek</sr> {current, number}/{max, number}",
|
||||
"featured_carousel.header": "{count, plural, one {{counter} zvýrazněný příspěvek} few {{counter} zvýrazněné příspěvky} many {{counter} zvýrazněných příspěvků} other {{counter} zvýrazněných příspěvků}}",
|
||||
"featured_carousel.slide": "Příspěvek {current, number} z {max, number}",
|
||||
"featured_tags.more_items": "+{count}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Tato kategorie filtrů se nevztahuje na kontext, ve kterém jste tento příspěvek otevřeli. Pokud chcete, aby byl příspěvek filtrován i v tomto kontextu, budete muset filtr upravit.",
|
||||
"filter_modal.added.context_mismatch_title": "Kontext se neshoduje!",
|
||||
"filter_modal.added.expired_explanation": "Tato kategorie filtrů vypršela, budete muset změnit datum vypršení platnosti, aby mohla být použita.",
|
||||
@@ -445,6 +510,7 @@
|
||||
"follow_suggestions.view_all": "Zobrazit vše",
|
||||
"follow_suggestions.who_to_follow": "Koho sledovat",
|
||||
"followed_tags": "Sledované hashtagy",
|
||||
"followers.hide_other_followers": "Tento uživatel se rozhodl nezveřejnit své další sledující",
|
||||
"footer.about": "O aplikaci",
|
||||
"footer.about_mastodon": "O Mastodonu",
|
||||
"footer.about_server": "O {domain}",
|
||||
@@ -456,6 +522,7 @@
|
||||
"footer.source_code": "Zobrazit zdrojový kód",
|
||||
"footer.status": "Stav",
|
||||
"footer.terms_of_service": "Obchodní podmínky",
|
||||
"form_field.optional": "(volitelné)",
|
||||
"generic.saved": "Uloženo",
|
||||
"getting_started.heading": "Začínáme",
|
||||
"hashtag.admin_moderation": "Otevřít moderátorské rozhraní pro #{name}",
|
||||
@@ -791,6 +858,7 @@
|
||||
"privacy.private.short": "Sledující",
|
||||
"privacy.public.long": "Kdokoliv na Mastodonu i mimo něj",
|
||||
"privacy.public.short": "Veřejné",
|
||||
"privacy.quote.anyone": "{visibility}, citování povoleno",
|
||||
"privacy.quote.disabled": "{visibility}, citování je zakázáno",
|
||||
"privacy.quote.limited": "{visibility}, citování je omezené",
|
||||
"privacy.unlisted.additional": "Chová se stejně jako veřejný, až na to, že se příspěvek neobjeví v živých kanálech nebo hashtazích, v objevování nebo vyhledávání na Mastodonu, a to i když je účet nastaven tak, aby se zde všude tyto příspěvky zobrazovaly.",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"account.add_or_remove_from_list": "Tilføj eller fjern fra lister",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Blokeret",
|
||||
"account.badges.bot": "Automatisert",
|
||||
"account.badges.bot": "Automatiseret",
|
||||
"account.badges.domain_blocked": "Blokeret domæne",
|
||||
"account.badges.group": "Gruppe",
|
||||
"account.badges.muted": "Skjult",
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Opret samling",
|
||||
"collections.delete_collection": "Slet samling",
|
||||
"collections.description_length_hint": "Begrænset til 100 tegn",
|
||||
"collections.detail.accounts_heading": "Konti",
|
||||
"collections.detail.curated_by_author": "Kurateret af {author}",
|
||||
"collections.detail.curated_by_you": "Kurateret af dig",
|
||||
"collections.detail.loading": "Indlæser samling…",
|
||||
"collections.detail.share": "Del denne samling",
|
||||
"collections.edit_details": "Rediger grundlæggende oplysninger",
|
||||
"collections.edit_settings": "Rediger indstillinger",
|
||||
"collections.error_loading_collections": "Der opstod en fejl under indlæsning af dine samlinger.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Sammlung erstellen",
|
||||
"collections.delete_collection": "Sammlung löschen",
|
||||
"collections.description_length_hint": "Maximal 100 Zeichen",
|
||||
"collections.detail.accounts_heading": "Konten",
|
||||
"collections.detail.curated_by_author": "Kuratiert von {author}",
|
||||
"collections.detail.curated_by_you": "Kuratiert von dir",
|
||||
"collections.detail.loading": "Sammlung wird geladen …",
|
||||
"collections.detail.share": "Sammlung teilen",
|
||||
"collections.edit_details": "Allgemeine Informationen bearbeiten",
|
||||
"collections.edit_settings": "Einstellungen bearbeiten",
|
||||
"collections.error_loading_collections": "Beim Laden deiner Sammlungen ist ein Fehler aufgetreten.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Δημιουργία συλλογής",
|
||||
"collections.delete_collection": "Διαγραφή συλλογής",
|
||||
"collections.description_length_hint": "Όριο 100 χαρακτήρων",
|
||||
"collections.detail.accounts_heading": "Λογαριασμοί",
|
||||
"collections.detail.curated_by_author": "Επιμέλεια από {author}",
|
||||
"collections.detail.curated_by_you": "Επιμέλεια από εσάς",
|
||||
"collections.detail.loading": "Γίνεται φόρτωση της συλλογής…",
|
||||
"collections.detail.share": "Κοινοποιήστε αυτήν τη συλλογή",
|
||||
"collections.edit_details": "Επεξεργασία βασικών στοιχείων",
|
||||
"collections.edit_settings": "Επεξεργασία ρυθμίσεων",
|
||||
"collections.error_loading_collections": "Παρουσιάστηκε σφάλμα κατά την προσπάθεια φόρτωσης των συλλογών σας.",
|
||||
|
||||
@@ -263,8 +263,12 @@
|
||||
"collections.create_collection": "Create collection",
|
||||
"collections.delete_collection": "Delete collection",
|
||||
"collections.description_length_hint": "100 characters limit",
|
||||
"collections.edit_details": "Edit basic details",
|
||||
"collections.edit_settings": "Edit settings",
|
||||
"collections.detail.accounts_heading": "Accounts",
|
||||
"collections.detail.curated_by_author": "Curated by {author}",
|
||||
"collections.detail.curated_by_you": "Curated by you",
|
||||
"collections.detail.loading": "Loading collection…",
|
||||
"collections.detail.share": "Share this collection",
|
||||
"collections.edit_details": "Edit details",
|
||||
"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",
|
||||
@@ -274,7 +278,7 @@
|
||||
"collections.manage_accounts_in_collection": "Manage accounts in this collection",
|
||||
"collections.mark_as_sensitive": "Mark as sensitive",
|
||||
"collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.",
|
||||
"collections.name_length_hint": "100 characters limit",
|
||||
"collections.name_length_hint": "40 characters limit",
|
||||
"collections.new_collection": "New collection",
|
||||
"collections.no_collections_yet": "No collections yet.",
|
||||
"collections.remove_account": "Remove this account",
|
||||
|
||||
@@ -141,8 +141,25 @@
|
||||
"account.unmute": "Unmute @{name}",
|
||||
"account.unmute_notifications_short": "Unmute notifications",
|
||||
"account.unmute_short": "Unmute",
|
||||
"account_edit.bio.placeholder": "Add a short introduction to help others identify you.",
|
||||
"account_edit.bio.title": "Bio",
|
||||
"account_edit.bio_modal.add_title": "Add bio",
|
||||
"account_edit.bio_modal.edit_title": "Edit bio",
|
||||
"account_edit.char_counter": "{currentLength}/{maxLength} characters",
|
||||
"account_edit.column_button": "Done",
|
||||
"account_edit.column_title": "Edit Profile",
|
||||
"account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else you’d like to share.",
|
||||
"account_edit.custom_fields.title": "Custom fields",
|
||||
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
|
||||
"account_edit.display_name.title": "Display name",
|
||||
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
|
||||
"account_edit.featured_hashtags.title": "Featured hashtags",
|
||||
"account_edit.name_modal.add_title": "Add display name",
|
||||
"account_edit.name_modal.edit_title": "Edit display name",
|
||||
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
|
||||
"account_edit.profile_tab.title": "Profile tab settings",
|
||||
"account_edit.save": "Save",
|
||||
"account_edit.section_edit_button": "Edit",
|
||||
"account_note.placeholder": "Click to add note",
|
||||
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
||||
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
||||
@@ -257,7 +274,6 @@
|
||||
"collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.",
|
||||
"collections.create.accounts_title": "Who will you feature in this collection?",
|
||||
"collections.create.basic_details_title": "Basic details",
|
||||
"collections.create.settings_title": "Settings",
|
||||
"collections.create.steps": "Step {step}/{total}",
|
||||
"collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.",
|
||||
"collections.create_collection": "Create collection",
|
||||
@@ -268,23 +284,22 @@
|
||||
"collections.detail.curated_by_you": "Curated by you",
|
||||
"collections.detail.loading": "Loading collection…",
|
||||
"collections.detail.share": "Share this collection",
|
||||
"collections.edit_details": "Edit basic details",
|
||||
"collections.edit_settings": "Edit settings",
|
||||
"collections.edit_details": "Edit details",
|
||||
"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.manage_accounts": "Manage accounts",
|
||||
"collections.manage_accounts_in_collection": "Manage accounts in this collection",
|
||||
"collections.mark_as_sensitive": "Mark as sensitive",
|
||||
"collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.",
|
||||
"collections.name_length_hint": "100 characters limit",
|
||||
"collections.name_length_hint": "40 characters limit",
|
||||
"collections.new_collection": "New collection",
|
||||
"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.sensitive": "Sensitive",
|
||||
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
||||
"collections.view_collection": "View collection",
|
||||
"collections.visibility_public": "Public",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Crear colección",
|
||||
"collections.delete_collection": "Eliminar colección",
|
||||
"collections.description_length_hint": "Límite de 100 caracteres",
|
||||
"collections.detail.accounts_heading": "Cuentas",
|
||||
"collections.detail.curated_by_author": "Curado por {author}",
|
||||
"collections.detail.curated_by_you": "Curado por vos",
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.edit_details": "Editar detalles básicos",
|
||||
"collections.edit_settings": "Editar configuración",
|
||||
"collections.error_loading_collections": "Hubo un error al intentar cargar tus colecciones.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Crear colección",
|
||||
"collections.delete_collection": "Eliminar colección",
|
||||
"collections.description_length_hint": "Limitado a 100 caracteres",
|
||||
"collections.detail.accounts_heading": "Cuentas",
|
||||
"collections.detail.curated_by_author": "Seleccionado por {author}",
|
||||
"collections.detail.curated_by_you": "Seleccionado por ti",
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.edit_details": "Editar detalles básicos",
|
||||
"collections.edit_settings": "Editar configuración",
|
||||
"collections.error_loading_collections": "Se produjo un error al intentar cargar tus colecciones.",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"account.unmute": "Dejar de silenciar a @{name}",
|
||||
"account.unmute_notifications_short": "Dejar de silenciar notificaciones",
|
||||
"account.unmute_short": "Dejar de silenciar",
|
||||
"account_edit.column_button": "Hecho",
|
||||
"account_edit.column_title": "Editar perfil",
|
||||
"account_note.placeholder": "Haz clic para añadir nota",
|
||||
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después del registro",
|
||||
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después del registro",
|
||||
@@ -261,6 +263,11 @@
|
||||
"collections.create_collection": "Crear colección",
|
||||
"collections.delete_collection": "Eliminar colección",
|
||||
"collections.description_length_hint": "Limitado a 100 caracteres",
|
||||
"collections.detail.accounts_heading": "Cuentas",
|
||||
"collections.detail.curated_by_author": "Seleccionado por {author}",
|
||||
"collections.detail.curated_by_you": "Seleccionado por ti",
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.edit_details": "Editar datos básicos",
|
||||
"collections.edit_settings": "Cambiar ajustes",
|
||||
"collections.error_loading_collections": "Se ha producido un error al intentar cargar tus colecciones.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Luo kokoelma",
|
||||
"collections.delete_collection": "Poista kokoelma",
|
||||
"collections.description_length_hint": "100 merkin rajoitus",
|
||||
"collections.detail.accounts_heading": "Tilit",
|
||||
"collections.detail.curated_by_author": "Koonnut {author}",
|
||||
"collections.detail.curated_by_you": "Itse kokoamasi",
|
||||
"collections.detail.loading": "Ladataan kokoelmaa…",
|
||||
"collections.detail.share": "Jaa tämä kokoelma",
|
||||
"collections.edit_details": "Muokkaa perustietoja",
|
||||
"collections.edit_settings": "Muokkaa asetuksia",
|
||||
"collections.error_loading_collections": "Kokoelmien latauksessa tapahtui virhe.",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"account.unmute": "Ne plus masquer @{name}",
|
||||
"account.unmute_notifications_short": "Ne plus masquer les notifications",
|
||||
"account.unmute_short": "Ne plus masquer",
|
||||
"account_edit.column_button": "Terminé",
|
||||
"account_edit.column_title": "Modifier le profil",
|
||||
"account_note.placeholder": "Cliquez pour ajouter une note",
|
||||
"admin.dashboard.daily_retention": "Taux de rétention des comptes par jour après inscription",
|
||||
"admin.dashboard.monthly_retention": "Taux de rétention des comptes par mois après inscription",
|
||||
@@ -261,6 +263,11 @@
|
||||
"collections.create_collection": "Créer une collection",
|
||||
"collections.delete_collection": "Supprimer la collection",
|
||||
"collections.description_length_hint": "Maximum 100 caractères",
|
||||
"collections.detail.accounts_heading": "Comptes",
|
||||
"collections.detail.curated_by_author": "Organisée par {author}",
|
||||
"collections.detail.curated_by_you": "Organisée par vous",
|
||||
"collections.detail.loading": "Chargement de la collection…",
|
||||
"collections.detail.share": "Partager la collection",
|
||||
"collections.edit_details": "Modifier les informations générales",
|
||||
"collections.edit_settings": "Modifier les paramètres",
|
||||
"collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"account.unmute": "Ne plus masquer @{name}",
|
||||
"account.unmute_notifications_short": "Réactiver les notifications",
|
||||
"account.unmute_short": "Ne plus masquer",
|
||||
"account_edit.column_button": "Terminé",
|
||||
"account_edit.column_title": "Modifier le profil",
|
||||
"account_note.placeholder": "Cliquez pour ajouter une note",
|
||||
"admin.dashboard.daily_retention": "Taux de rétention des utilisateur·rice·s par jour après inscription",
|
||||
"admin.dashboard.monthly_retention": "Taux de rétention des utilisateur·rice·s par mois après inscription",
|
||||
@@ -261,6 +263,11 @@
|
||||
"collections.create_collection": "Créer une collection",
|
||||
"collections.delete_collection": "Supprimer la collection",
|
||||
"collections.description_length_hint": "Maximum 100 caractères",
|
||||
"collections.detail.accounts_heading": "Comptes",
|
||||
"collections.detail.curated_by_author": "Organisée par {author}",
|
||||
"collections.detail.curated_by_you": "Organisée par vous",
|
||||
"collections.detail.loading": "Chargement de la collection…",
|
||||
"collections.detail.share": "Partager la collection",
|
||||
"collections.edit_details": "Modifier les informations générales",
|
||||
"collections.edit_settings": "Modifier les paramètres",
|
||||
"collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"account.unmute": "Díbhalbhaigh @{name}",
|
||||
"account.unmute_notifications_short": "Díbhalbhaigh fógraí",
|
||||
"account.unmute_short": "Díbhalbhaigh",
|
||||
"account_edit.column_button": "Déanta",
|
||||
"account_edit.column_title": "Cuir Próifíl in Eagar",
|
||||
"account_note.placeholder": "Cliceáil chun nóta a chuir leis",
|
||||
"admin.dashboard.daily_retention": "Ráta coinneála an úsáideora de réir an lae tar éis clárú",
|
||||
"admin.dashboard.monthly_retention": "Ráta coinneála na n-úsáideoirí de réir na míosa tar éis dóibh clárú",
|
||||
@@ -261,6 +263,11 @@
|
||||
"collections.create_collection": "Cruthaigh bailiúchán",
|
||||
"collections.delete_collection": "Scrios bailiúchán",
|
||||
"collections.description_length_hint": "Teorainn 100 carachtar",
|
||||
"collections.detail.accounts_heading": "Cuntais",
|
||||
"collections.detail.curated_by_author": "Curtha i dtoll a chéile ag {author}",
|
||||
"collections.detail.curated_by_you": "Curtha i dtoll a chéile agatsa",
|
||||
"collections.detail.loading": "Ag lódáil an bhailiúcháin…",
|
||||
"collections.detail.share": "Comhroinn an bailiúchán seo",
|
||||
"collections.edit_details": "Cuir sonraí bunúsacha in eagar",
|
||||
"collections.edit_settings": "Socruithe a chur in eagar",
|
||||
"collections.error_loading_collections": "Tharla earráid agus iarracht á déanamh do bhailiúcháin a luchtú.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Crear colección",
|
||||
"collections.delete_collection": "Eliminar colección",
|
||||
"collections.description_length_hint": "Límite de 100 caracteres",
|
||||
"collections.detail.accounts_heading": "Contas",
|
||||
"collections.detail.curated_by_author": "Seleccionadas por {author}",
|
||||
"collections.detail.curated_by_you": "Seleccionadas por ti",
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.edit_details": "Editar detalles básicos",
|
||||
"collections.edit_settings": "Editar axustes",
|
||||
"collections.error_loading_collections": "Houbo un erro ao intentar cargar as túas coleccións.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "יצירת אוסף",
|
||||
"collections.delete_collection": "מחיקת האוסף",
|
||||
"collections.description_length_hint": "מגבלה של 100 תווים",
|
||||
"collections.detail.accounts_heading": "חשבונות",
|
||||
"collections.detail.curated_by_author": "נאצר על ידי {author}",
|
||||
"collections.detail.curated_by_you": "נאצר על ידיך",
|
||||
"collections.detail.loading": "טוען אוסף…",
|
||||
"collections.detail.share": "שיתוף אוסף",
|
||||
"collections.edit_details": "עריכת פרטים בסיסיים",
|
||||
"collections.edit_settings": "עריכת הגדרות",
|
||||
"collections.error_loading_collections": "חלה שגיאה בנסיון לטעון את אוספיך.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Búa til safn",
|
||||
"collections.delete_collection": "Eyða safni",
|
||||
"collections.description_length_hint": "100 stafa takmörk",
|
||||
"collections.detail.accounts_heading": "Aðgangar",
|
||||
"collections.detail.curated_by_author": "Safnað saman af {author}",
|
||||
"collections.detail.curated_by_you": "Safnað saman af þér",
|
||||
"collections.detail.loading": "Hleð inn safni…",
|
||||
"collections.detail.share": "Deila þessu safni",
|
||||
"collections.edit_details": "Breyta grunnupplýsingum",
|
||||
"collections.edit_settings": "Breyta stillingum",
|
||||
"collections.error_loading_collections": "Villa kom upp þegar reynt var að hlaða inn söfnunum þínum.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Crea la collezione",
|
||||
"collections.delete_collection": "Cancella la collezione",
|
||||
"collections.description_length_hint": "Limite di 100 caratteri",
|
||||
"collections.detail.accounts_heading": "Account",
|
||||
"collections.detail.curated_by_author": "Curata da {author}",
|
||||
"collections.detail.curated_by_you": "Curata da te",
|
||||
"collections.detail.loading": "Caricamento della collezione…",
|
||||
"collections.detail.share": "Condividi questa collezione",
|
||||
"collections.edit_details": "Modifica i dettagli di base",
|
||||
"collections.edit_settings": "Modifica impostazioni",
|
||||
"collections.error_loading_collections": "Si è verificato un errore durante il tentativo di caricare le tue collezioni.",
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"account.about": "정보",
|
||||
"account.account_note_header": "개인 메모",
|
||||
"account.activity": "활동",
|
||||
"account.add_note": "개인 메모 추가",
|
||||
"account.add_or_remove_from_list": "리스트에 추가 혹은 삭제",
|
||||
"account.badges.admin": "관리자",
|
||||
"account.badges.blocked": "차단함",
|
||||
"account.badges.bot": "자동화됨",
|
||||
"account.badges.domain_blocked": "차단한 도메인",
|
||||
@@ -33,6 +35,7 @@
|
||||
"account.direct": "@{name} 님에게 개인 멘션",
|
||||
"account.disable_notifications": "@{name} 의 게시물 알림 끄기",
|
||||
"account.domain_blocking": "도메인 차단함",
|
||||
"account.edit_note": "개인 메모 편집",
|
||||
"account.edit_profile": "프로필 편집",
|
||||
"account.edit_profile_short": "수정",
|
||||
"account.enable_notifications": "@{name} 의 게시물 알림 켜기",
|
||||
@@ -45,6 +48,7 @@
|
||||
"account.featured.hashtags": "해시태그",
|
||||
"account.featured_tags.last_status_at": "{date}에 마지막으로 게시",
|
||||
"account.featured_tags.last_status_never": "게시물 없음",
|
||||
"account.filters.all": "모든 활동",
|
||||
"account.filters.boosts_toggle": "부스트 보기",
|
||||
"account.filters.replies_toggle": "답글 보기",
|
||||
"account.follow": "팔로우",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"account.unmute": "@{name} niet langer negeren",
|
||||
"account.unmute_notifications_short": "Meldingen niet langer negeren",
|
||||
"account.unmute_short": "Niet langer negeren",
|
||||
"account_edit.column_button": "Klaar",
|
||||
"account_edit.column_title": "Profiel bewerken",
|
||||
"account_note.placeholder": "Klik om een opmerking toe te voegen",
|
||||
"admin.dashboard.daily_retention": "Retentiegraad van gebruikers per dag, vanaf registratie",
|
||||
"admin.dashboard.monthly_retention": "Retentiegraad van gebruikers per maand, vanaf registratie",
|
||||
@@ -244,9 +246,12 @@
|
||||
"closed_registrations_modal.preamble": "Mastodon is gedecentraliseerd. Op welke server je ook een account hebt, je kunt overal vandaan mensen op deze server volgen en er mee interactie hebben. Je kunt zelfs zelf een Mastodon-server hosten!",
|
||||
"closed_registrations_modal.title": "Registreren op Mastodon",
|
||||
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
|
||||
"collections.accounts.empty_description": "Voeg tot {count} accounts toe die je volgt",
|
||||
"collections.accounts.empty_title": "Deze verzameling is leeg",
|
||||
"collections.collection_description": "Omschrijving",
|
||||
"collections.collection_name": "Naam",
|
||||
"collections.collection_topic": "Onderwerp",
|
||||
"collections.confirm_account_removal": "Weet je zeker dat je dit account uit deze verzameling wilt verwijderen?",
|
||||
"collections.content_warning": "Inhoudswaarschuwing",
|
||||
"collections.continue": "Doorgaan",
|
||||
"collections.create.accounts_subtitle": "Alleen accounts die je volgt en ontdekt willen worden, kunnen worden toegevoegd.",
|
||||
@@ -258,9 +263,17 @@
|
||||
"collections.create_collection": "Verzameling aanmaken",
|
||||
"collections.delete_collection": "Verzameling verwijderen",
|
||||
"collections.description_length_hint": "Maximaal 100 karakters",
|
||||
"collections.detail.accounts_heading": "Accounts",
|
||||
"collections.detail.curated_by_author": "Samengesteld door {author}",
|
||||
"collections.detail.curated_by_you": "Samengesteld door jou",
|
||||
"collections.detail.loading": "Verzameling laden…",
|
||||
"collections.detail.share": "Deze verzameling delen",
|
||||
"collections.edit_details": "Basisgegevens bewerken",
|
||||
"collections.edit_settings": "Instellingen bewerken",
|
||||
"collections.error_loading_collections": "Er is een fout opgetreden bij het laden van je verzamelingen.",
|
||||
"collections.hints.accounts_counter": "{count} / {max} accounts",
|
||||
"collections.hints.add_more_accounts": "Voeg ten minste {count, plural, one {# account} other {# accounts}} toe om door te gaan",
|
||||
"collections.hints.can_not_remove_more_accounts": "Verzamelingen moeten ten minste {count, plural, one {# account} other {# accounts}} bevatten. Meer accounts verwijderen is niet mogelijk.",
|
||||
"collections.last_updated_at": "Laatst bijgewerkt: {date}",
|
||||
"collections.manage_accounts": "Accounts beheren",
|
||||
"collections.manage_accounts_in_collection": "Accounts in deze verzameling beheren",
|
||||
@@ -269,6 +282,9 @@
|
||||
"collections.name_length_hint": "100 tekens limiet",
|
||||
"collections.new_collection": "Nieuwe verzameling",
|
||||
"collections.no_collections_yet": "Nog geen verzamelingen.",
|
||||
"collections.remove_account": "Deze account verwijderen",
|
||||
"collections.search_accounts_label": "Zoek naar accounts om toe te voegen…",
|
||||
"collections.search_accounts_max_reached": "Je hebt het maximum aantal accounts toegevoegd",
|
||||
"collections.topic_hint": "Voeg een hashtag toe die anderen helpt het hoofdonderwerp van deze collectie te begrijpen.",
|
||||
"collections.view_collection": "Verzameling bekijken",
|
||||
"collections.visibility_public": "Openbaar",
|
||||
|
||||
@@ -260,6 +260,11 @@
|
||||
"collections.create_collection": "Krijoni koleksion",
|
||||
"collections.delete_collection": "Fshije koleksionin",
|
||||
"collections.description_length_hint": "Kufi prej 100 shenjash",
|
||||
"collections.detail.accounts_heading": "Llogari",
|
||||
"collections.detail.curated_by_author": "Në kujdesin e {author}",
|
||||
"collections.detail.curated_by_you": "Nën kujdesin tuaj",
|
||||
"collections.detail.loading": "Po ngarkohet koleksion…",
|
||||
"collections.detail.share": "Ndajeni këtë koleksion me të tjerë",
|
||||
"collections.edit_details": "Përpunoni hollësi bazë",
|
||||
"collections.edit_settings": "Përpunoni rregullime",
|
||||
"collections.error_loading_collections": "Pati një gabim teksa provohej të ngarkoheshin koleksionet tuaj.",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"collections.create_a_collection_hint": "Skapa en samling för att rekommendera eller dela dina favoritkonton med andra.",
|
||||
"collections.create_collection": "Skapa samling",
|
||||
"collections.delete_collection": "Radera samling",
|
||||
"collections.detail.accounts_heading": "Konton",
|
||||
"collections.error_loading_collections": "Det uppstod ett fel när dina samlingar skulle laddas.",
|
||||
"collections.hints.accounts_counter": "{count} / {max} konton",
|
||||
"collections.no_collections_yet": "Inga samlingar än.",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"account.unmute": "@{name} adlı kişinin sesini aç",
|
||||
"account.unmute_notifications_short": "Bildirimlerin sesini aç",
|
||||
"account.unmute_short": "Susturmayı kaldır",
|
||||
"account_edit.column_button": "Tamamlandı",
|
||||
"account_edit.column_title": "Profili Düzenle",
|
||||
"account_note.placeholder": "Not eklemek için tıklayın",
|
||||
"admin.dashboard.daily_retention": "Kayıttan sonra günlük kullanıcı saklama oranı",
|
||||
"admin.dashboard.monthly_retention": "Kayıttan sonra aylık kullanıcı saklama oranı",
|
||||
@@ -261,6 +263,11 @@
|
||||
"collections.create_collection": "Koleksiyon oluştur",
|
||||
"collections.delete_collection": "Koleksiyonu sil",
|
||||
"collections.description_length_hint": "100 karakterle sınırlı",
|
||||
"collections.detail.accounts_heading": "Hesaplar",
|
||||
"collections.detail.curated_by_author": "{author} tarafından derlenen",
|
||||
"collections.detail.curated_by_you": "Sizin derledikleriniz",
|
||||
"collections.detail.loading": "Koleksiyon yükleniyor…",
|
||||
"collections.detail.share": "Bu koleksiyonu paylaş",
|
||||
"collections.edit_details": "Temel bilgileri düzenle",
|
||||
"collections.edit_settings": "Ayarları düzenle",
|
||||
"collections.error_loading_collections": "Koleksiyonlarınızı yüklemeye çalışırken bir hata oluştu.",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "Tạo collection",
|
||||
"collections.delete_collection": "Xóa collection",
|
||||
"collections.description_length_hint": "Giới hạn 100 ký tự",
|
||||
"collections.detail.accounts_heading": "Tài khoản",
|
||||
"collections.detail.curated_by_author": "Tuyển chọn bởi {author}",
|
||||
"collections.detail.curated_by_you": "Tuyển chọn bởi bạn",
|
||||
"collections.detail.loading": "Đang tải collection…",
|
||||
"collections.detail.share": "Chia sẻ collection này",
|
||||
"collections.edit_details": "Sửa thông tin cơ bản",
|
||||
"collections.edit_settings": "Sửa cài đặt",
|
||||
"collections.error_loading_collections": "Đã xảy ra lỗi khi tải những collection của bạn.",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"account.unmute": "不再隐藏 @{name}",
|
||||
"account.unmute_notifications_short": "恢复通知",
|
||||
"account.unmute_short": "取消隐藏",
|
||||
"account_edit.column_button": "完成",
|
||||
"account_edit.column_title": "修改个人资料",
|
||||
"account_note.placeholder": "点击添加备注",
|
||||
"admin.dashboard.daily_retention": "注册后用户留存率(按日计算)",
|
||||
"admin.dashboard.monthly_retention": "注册后用户留存率(按月计算)",
|
||||
@@ -261,6 +263,11 @@
|
||||
"collections.create_collection": "创建收藏列表",
|
||||
"collections.delete_collection": "删除收藏列表",
|
||||
"collections.description_length_hint": "100字限制",
|
||||
"collections.detail.accounts_heading": "账号",
|
||||
"collections.detail.curated_by_author": "由 {author} 精心挑选",
|
||||
"collections.detail.curated_by_you": "由你精心挑选",
|
||||
"collections.detail.loading": "正在加载收藏列表…",
|
||||
"collections.detail.share": "分享此收藏列表",
|
||||
"collections.edit_details": "编辑基本信息",
|
||||
"collections.edit_settings": "编辑设置",
|
||||
"collections.error_loading_collections": "加载你的收藏列表时发生错误。",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"collections.create_collection": "建立收藏名單",
|
||||
"collections.delete_collection": "刪除收藏名單",
|
||||
"collections.description_length_hint": "100 字限制",
|
||||
"collections.detail.accounts_heading": "帳號",
|
||||
"collections.detail.curated_by_author": "由 {author} 精選",
|
||||
"collections.detail.curated_by_you": "由您精選",
|
||||
"collections.detail.loading": "讀取收藏名單中...",
|
||||
"collections.detail.share": "分享此收藏名單",
|
||||
"collections.edit_details": "編輯基本資料",
|
||||
"collections.edit_settings": "編輯設定",
|
||||
"collections.error_loading_collections": "讀取您的收藏名單時發生錯誤。",
|
||||
|
||||
@@ -69,6 +69,11 @@ export const accountDefaultValues: AccountShape = {
|
||||
display_name: '',
|
||||
display_name_html: '',
|
||||
emojis: ImmutableList<CustomEmoji>(),
|
||||
feature_approval: {
|
||||
automatic: [],
|
||||
manual: [],
|
||||
current_user: 'missing',
|
||||
},
|
||||
fields: ImmutableList<AccountField>(),
|
||||
group: false,
|
||||
header: '',
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
} from '@/mastodon/api_types/collections';
|
||||
import { me } from '@/mastodon/initial_state';
|
||||
import {
|
||||
createAppSelector,
|
||||
createDataLoadingThunk,
|
||||
@@ -111,6 +112,14 @@ const collectionSlice = createSlice({
|
||||
const { collectionId } = action.meta.arg;
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete state.collections[collectionId];
|
||||
if (me) {
|
||||
let accountCollectionIds = state.accountCollections[me]?.collectionIds;
|
||||
if (accountCollectionIds) {
|
||||
accountCollectionIds = accountCollectionIds.filter(
|
||||
(id) => id !== collectionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,11 @@ export const accountFactory: FactoryFunction<ApiAccountJSON> = ({
|
||||
created_at: '2023-01-01T00:00:00.000Z',
|
||||
discoverable: true,
|
||||
emojis: [],
|
||||
feature_approval: {
|
||||
automatic: [],
|
||||
manual: [],
|
||||
current_user: 'missing',
|
||||
},
|
||||
fields: [],
|
||||
followers_count: 0,
|
||||
following_count: 0,
|
||||
|
||||
@@ -26,6 +26,7 @@ class Collection < ApplicationRecord
|
||||
belongs_to :tag, optional: true
|
||||
|
||||
has_many :collection_items, dependent: :delete_all
|
||||
has_many :accepted_collection_items, -> { accepted }, class_name: 'CollectionItem', inverse_of: :collection # rubocop:disable Rails/HasManyOrHasOneDependent
|
||||
has_many :collection_reports, dependent: :delete_all
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.batch-table__row{ class: [!account.unavailable? && account.user_pending? && 'batch-table__row--attention', (account.unavailable? || account.user_unconfirmed?) && 'batch-table__row--muted'] }
|
||||
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
|
||||
= f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
|
||||
- if local_assigns[:f].present?
|
||||
= f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
|
||||
.batch-table__row__content.batch-table__row__content--unpadded
|
||||
%table.accounts-table
|
||||
%tbody
|
||||
|
||||
21
app/views/admin/collections/show.html.haml
Normal file
21
app/views/admin/collections/show.html.haml
Normal file
@@ -0,0 +1,21 @@
|
||||
- content_for :page_title do
|
||||
= t('admin.collections.collection_title', name: @account.pretty_acct)
|
||||
|
||||
- content_for :heading_actions do
|
||||
= link_to t('admin.collections.open'), account_collection_path(@account, @collection), class: 'button', target: '_blank', rel: 'noopener'
|
||||
|
||||
%h3= t('admin.collections.contents')
|
||||
|
||||
= render 'admin/shared/collection', collection: @collection
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
%h3= t('admin.collections.accounts')
|
||||
|
||||
.batch-table
|
||||
.batch-table__toolbar
|
||||
.batch-table__body
|
||||
- if @collection.accepted_collection_items.none?
|
||||
= nothing_here 'nothing-here--under-tabs'
|
||||
- else
|
||||
= render partial: 'admin/accounts/account', collection: @collection.accepted_collection_items.map(&:account)
|
||||
@@ -67,6 +67,11 @@
|
||||
= material_symbol('photo_camera')
|
||||
= report.media_attachments_count
|
||||
|
||||
- if Mastodon::Feature.collections_enabled?
|
||||
%span.report-card__summary__item__content__icon{ title: t('admin.accounts.collections') }
|
||||
= material_symbol('groups-fill')
|
||||
= report.collections.size
|
||||
|
||||
- if report.forwarded?
|
||||
·
|
||||
= t('admin.reports.forwarded_to', domain: target_account.domain)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
%hr.spacer/
|
||||
|
||||
%h3
|
||||
= t 'admin.reports.statuses'
|
||||
= t 'admin.reports.reported_content'
|
||||
%small.section-skip-link
|
||||
= link_to '#actions' do
|
||||
= material_symbol 'keyboard_double_arrow_down'
|
||||
@@ -41,6 +41,9 @@
|
||||
%p
|
||||
= t 'admin.reports.statuses_description_html'
|
||||
|
||||
%h4
|
||||
= t 'admin.reports.statuses'
|
||||
|
||||
= form_with model: @form, url: batch_admin_account_statuses_path(@report.target_account_id, report_id: @report.id) do |f|
|
||||
.batch-table
|
||||
.batch-table__toolbar
|
||||
@@ -58,6 +61,22 @@
|
||||
- else
|
||||
= render partial: 'admin/shared/status_batch_row', collection: @statuses, as: :status, locals: { f: f }
|
||||
|
||||
- if Mastodon::Feature.collections_enabled?
|
||||
%h4
|
||||
= t 'admin.reports.collections'
|
||||
|
||||
%form
|
||||
.batch-table
|
||||
.batch-table__toolbar
|
||||
%label.batch-table__toolbar__select.batch-checkbox-all
|
||||
-# = check_box_tag :batch_checkbox_all, nil, false
|
||||
.batch-table__toolbar__actions
|
||||
.batch-table__body
|
||||
- if @report.collections.empty?
|
||||
= nothing_here 'nothing-here--under-tabs'
|
||||
- else
|
||||
= render partial: 'admin/shared/collection_batch_row', collection: @report.collections, as: :collection
|
||||
|
||||
- if @report.unresolved?
|
||||
%hr.spacer/
|
||||
|
||||
|
||||
22
app/views/admin/shared/_collection.html.haml
Normal file
22
app/views/admin/shared/_collection.html.haml
Normal file
@@ -0,0 +1,22 @@
|
||||
.status__card
|
||||
- if collection.tag.present?
|
||||
.status__prepend
|
||||
= link_to collection.tag.formatted_name, admin_tag_path(collection.tag_id)
|
||||
|
||||
.status__content
|
||||
%h6= collection.name
|
||||
|
||||
%p= collection.description
|
||||
|
||||
.detailed-status__meta
|
||||
= conditional_link_to can?(:show, collection), admin_account_collection_path(collection.account.id, collection), class: 'detailed-status__datetime' do
|
||||
%time.formatted{ datetime: collection.created_at.iso8601, title: l(collection.created_at) }><= l(collection.created_at)
|
||||
- if collection.sensitive?
|
||||
·
|
||||
= material_symbol('visibility_off')
|
||||
= t('stream_entries.sensitive_content')
|
||||
·
|
||||
= t('admin.collections.number_of_accounts', count: collection.accepted_collection_items.size)
|
||||
·
|
||||
= link_to account_collection_path(collection.account, collection), class: 'detailed-status__link', target: 'blank', rel: 'noopener' do
|
||||
= t('admin.collections.view_publicly')
|
||||
5
app/views/admin/shared/_collection_batch_row.html.haml
Normal file
5
app/views/admin/shared/_collection_batch_row.html.haml
Normal file
@@ -0,0 +1,5 @@
|
||||
.batch-table__row
|
||||
%label.batch-table__row__select.batch-checkbox
|
||||
-# = f.check_box :collection_ids, { multiple: true, include_hidden: false }, collection.id
|
||||
.batch-table__row__content
|
||||
= render partial: 'admin/shared/collection', object: collection
|
||||
@@ -585,7 +585,6 @@ an:
|
||||
resolved_msg: La denuncia s'ha resuelto correctament!
|
||||
skip_to_actions: Ir dreitament a las accions
|
||||
status: Estau
|
||||
statuses: Conteniu denunciau
|
||||
statuses_description_html: Lo conteniu ofensivo se citará en a comunicación con a cuenta denunciada
|
||||
target_origin: Orichen d'a cuenta denunciada
|
||||
title: Reportes
|
||||
|
||||
@@ -752,7 +752,6 @@ ar:
|
||||
resolved_msg: تمت معالجة الشكوى بنجاح!
|
||||
skip_to_actions: تخطي إلى الإجراءات
|
||||
status: الحالة
|
||||
statuses: المحتوى المبلغ عنه
|
||||
statuses_description_html: سيشار إلى المحتوى المخالف في الاتصال بالحساب المبلغ عنه
|
||||
summary:
|
||||
action_preambles:
|
||||
|
||||
@@ -261,7 +261,6 @@ ast:
|
||||
resolved_msg: "¡L'informe resolvióse correutamente!"
|
||||
skip_to_actions: Saltar a les aiciones
|
||||
status: Estáu
|
||||
statuses: Conteníu del que s'informó
|
||||
statuses_description_html: El conteníu ofensivu cítase na comunicación cola cuenta de la que s'informó
|
||||
target_origin: Orixe de la cuenta de la que s'infomó
|
||||
title: Informes
|
||||
|
||||
@@ -739,7 +739,6 @@ be:
|
||||
resolved_msg: Скарга была паспяхова вырашана!
|
||||
skip_to_actions: Прапусціць дзеянні
|
||||
status: Стан
|
||||
statuses: Змесціва, на якое паскардзіліся
|
||||
statuses_description_html: Крыўднае змесціва будзе згадвацца ў зносінах з уліковым запісам, на які пададзена скарга
|
||||
summary:
|
||||
action_preambles:
|
||||
|
||||
@@ -696,7 +696,6 @@ bg:
|
||||
resolved_msg: Успешно разрешен доклад!
|
||||
skip_to_actions: Прескок към действия
|
||||
status: Състояние
|
||||
statuses: Докладвано съдържание
|
||||
statuses_description_html: Обидно съдържание ще се цитира в общуването с докладвания акаунт
|
||||
summary:
|
||||
action_preambles:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user