Bookmark folders

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-03-20 23:58:53 +01:00
parent 460e22ce2b
commit eceafedec4
31 changed files with 748 additions and 60 deletions

View File

@ -14,6 +14,7 @@ import {
CryptoDonateModal,
DislikesModal,
EditAnnouncementModal,
EditBookmarkFolderModal,
EditFederationModal,
EmbedModal,
EventMapModal,
@ -36,6 +37,7 @@ import {
ReblogsModal,
ReplyMentionsModal,
ReportModal,
SelectBookmarkFolderModal,
UnauthorizedModal,
VideoModal,
} from 'soapbox/features/ui/util/async-components';
@ -57,6 +59,7 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
'CRYPTO_DONATE': CryptoDonateModal,
'DISLIKES': DislikesModal,
'EDIT_ANNOUNCEMENT': EditAnnouncementModal,
'EDIT_BOOKMARK_FOLDER': EditBookmarkFolderModal,
'EDIT_FEDERATION': EditFederationModal,
'EMBED': EmbedModal,
'EVENT_MAP': EventMapModal,
@ -78,6 +81,7 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
'REBLOGS': ReblogsModal,
'REPLY_MENTIONS': ReplyMentionsModal,
'REPORT': ReportModal,
'SELECT_BOOKMARK_FOLDER': SelectBookmarkFolderModal,
'UNAUTHORIZED': UnauthorizedModal,
'VIDEO': VideoModal,
};

View File

@ -12,7 +12,6 @@ import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles } from 'soa
import ComposeForm from '../../../compose/components/compose-form';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' },
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
});

View File

@ -0,0 +1,160 @@
import { useFloating, shift } from '@floating-ui/react';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { closeModal } from 'soapbox/actions/modals';
import { useBookmarkFolder, useUpdateBookmarkFolder } from 'soapbox/api/hooks';
import { Emoji, HStack, Icon, Input, Modal } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { messages as emojiMessages } from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import { useAppDispatch, useClickOutside } from 'soapbox/hooks';
import { useTextField } from 'soapbox/hooks/forms';
import toast from 'soapbox/toast';
import type { Emoji as EmojiType } from 'soapbox/features/emoji';
const messages = defineMessages({
label: { id: 'bookmark_folders.new.title_placeholder', defaultMessage: 'New folder title' },
editSuccess: { id: 'bookmark_folders.edit.success', defaultMessage: 'Bookmark folder edited successfully' },
editFail: { id: 'bookmark_folders.edit.fail', defaultMessage: 'Failed to edit bookmark folder' },
});
interface IEmojiPicker {
emoji?: string;
emojiUrl?: string;
onPickEmoji?: (emoji: EmojiType) => void;
}
const EmojiPicker: React.FC<IEmojiPicker> = ({ emoji, emojiUrl, ...props }) => {
const intl = useIntl();
const title = intl.formatMessage(emojiMessages.emoji);
const [visible, setVisible] = useState(false);
const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({
middleware: [shift()],
});
useClickOutside(refs, () => {
setVisible(false);
});
const handleToggle: React.KeyboardEventHandler<HTMLButtonElement> & React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
setVisible(!visible);
};
return (
<div className='relative'>
<button
className='mt-1 flex h-[38px] w-[38px] items-center justify-center rounded-md border border-solid border-gray-400 bg-white text-gray-900 ring-1 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-gray-800 dark:focus:border-primary-500 dark:focus:ring-primary-500'
ref={refs.setReference}
title={title}
aria-label={title}
aria-expanded={visible}
onClick={handleToggle}
onKeyDown={handleToggle}
tabIndex={0}
>
{emoji
? <Emoji height={20} width={20} emoji={emoji} />
: <Icon className='h-5 w-5 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500' src={require('@tabler/icons/mood-happy.svg')} />}
</button>
{createPortal(
<div
className='z-[101]'
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
>
<EmojiPickerDropdown
visible={visible}
setVisible={setVisible}
update={update}
{...props}
/>
</div>,
document.body,
)}
</div>
);
};
interface IEditBookmarkFolderModal {
folderId: string;
onClose: (type: string) => void;
}
const EditBookmarkFolderModal: React.FC<IEditBookmarkFolderModal> = ({ folderId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { bookmarkFolder } = useBookmarkFolder(folderId);
const { updateBookmarkFolder, isSubmitting } = useUpdateBookmarkFolder(folderId);
const [emoji, setEmoji] = useState(bookmarkFolder?.emoji);
const [emojiUrl, setEmojiUrl] = useState(bookmarkFolder?.emoji_url);
const name = useTextField(bookmarkFolder?.name);
const handleEmojiPick = (data: EmojiType) => {
if (data.custom) {
setEmojiUrl(data.imageUrl);
setEmoji(data.colons);
} else {
setEmoji(data.native);
}
};
const onClickClose = () => {
onClose('EDIT_BOOKMARK_FOLDER');
};
const handleSubmit = () => {
updateBookmarkFolder({
name: name.value,
emoji,
}).then(() => {
toast.success(intl.formatMessage(messages.editSuccess));
dispatch(closeModal('EDIT_BOOKMARK_FOLDER'));
})
.catch(() => {
toast.success(intl.formatMessage(messages.editFail));
});
};
const label = intl.formatMessage(messages.label);
return (
<Modal
title={<FormattedMessage id='edit_bookmark_folder_modal.header_title' defaultMessage='Edit folder' />}
onClose={onClickClose}
confirmationAction={handleSubmit}
confirmationText={<FormattedMessage id='edit_bookmark_folder_modal.confirm' defaultMessage='Save' />}
>
<HStack space={2}>
<EmojiPicker
emoji={emoji}
emojiUrl={emojiUrl}
onPickEmoji={handleEmojiPick}
/>
<label className='grow'>
<span style={{ display: 'none' }}>{label}</span>
<Input
type='text'
placeholder={label}
disabled={isSubmitting}
{...name}
/>
</label>
</HStack>
</Modal>
);
};
export default EditBookmarkFolderModal;

View File

@ -13,7 +13,6 @@ import { ReactionRecord } from 'soapbox/reducers/user-lists';
import type { Item } from 'soapbox/components/ui/tabs/tabs';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
all: { id: 'reactions.all', defaultMessage: 'All' },
});

View File

@ -0,0 +1,96 @@
import React, { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { bookmark } from 'soapbox/actions/interactions';
import { useBookmarkFolders } from 'soapbox/api/hooks';
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
import { Emoji, HStack, Icon, Modal, Spinner, Stack } from 'soapbox/components/ui';
import NewFolderForm from 'soapbox/features/bookmark-folders/components/new-folder-form';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
import type { Status as StatusEntity } from 'soapbox/types/entities';
interface ISelectBookmarkFolderModal {
statusId: string;
onClose: (type: string) => void;
}
const SelectBookmarkFolderModal: React.FC<ISelectBookmarkFolderModal> = ({ statusId, onClose }) => {
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: statusId })) as StatusEntity;
const dispatch = useAppDispatch();
const [selectedFolder, setSelectedFolder] = useState(status.pleroma.get('bookmark_folder'));
const { isFetching, bookmarkFolders } = useBookmarkFolders();
const onChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const folderId = e.target.value;
setSelectedFolder(folderId);
dispatch(bookmark(status, folderId)).then(() => {
onClose('SELECT_BOOKMARK_FOLDER');
}).catch(() => {});
};
const onClickClose = () => {
onClose('SELECT_BOOKMARK_FOLDER');
};
const items = [
<RadioItem
label={
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/bookmarks.svg')} size={20} />
<span><FormattedMessage id='bookmark_folders.all_bookmarks' defaultMessage='All bookmarks' /></span>
</HStack>
}
checked={selectedFolder === null}
value={''}
/>,
];
if (!isFetching) {
items.push(...(bookmarkFolders.map((folder) => (
<RadioItem
key={folder.id}
label={
<HStack alignItems='center' space={2}>
{folder.emoji ? (
<Emoji
emoji={folder.emoji}
src={folder.emoji_url || undefined}
className='h-5 w-5 flex-none'
/>
) : <Icon src={require('@tabler/icons/folder.svg')} size={20} />}
<span>{folder.name}</span>
</HStack>
}
checked={selectedFolder === folder.id}
value={folder.id}
/>
))));
}
const body = isFetching ? <Spinner /> : (
<Stack space={4}>
<NewFolderForm />
<RadioGroup onChange={onChange}>
{items}
</RadioGroup>
</Stack>
);
return (
<Modal
title={<FormattedMessage id='select_bookmark_folder_modal.header_title' defaultMessage='Select folder' />}
onClose={onClickClose}
>
{body}
</Modal>
);
};
export default SelectBookmarkFolderModal;

View File

@ -9,7 +9,6 @@ import { selectAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
accountPlaceholder: { id: 'remote_interaction.account_placeholder', defaultMessage: 'Enter your username@domain you want to act from' },
userNotFoundError: { id: 'remote_interaction.user_not_found_error', defaultMessage: 'Couldn\'t find given user' },
});