# Conflicts: # src/features/bookmarks/index.tsxenvironments/review-black-mode-td9fvr/deployments/4480
commit
4871c30b8c
@ -0,0 +1,31 @@
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { selectEntity } from 'soapbox/entity-store/selectors';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { type BookmarkFolder } from 'soapbox/schemas/bookmark-folder';
|
||||
|
||||
import { useBookmarkFolders } from './useBookmarkFolders';
|
||||
|
||||
function useBookmarkFolder(folderId?: string) {
|
||||
const {
|
||||
isError,
|
||||
isFetched,
|
||||
isFetching,
|
||||
isLoading,
|
||||
invalidate,
|
||||
} = useBookmarkFolders();
|
||||
|
||||
const bookmarkFolder = useAppSelector(state => folderId
|
||||
? selectEntity<BookmarkFolder>(state, Entities.BOOKMARK_FOLDERS, folderId)
|
||||
: undefined);
|
||||
|
||||
return {
|
||||
bookmarkFolder,
|
||||
isError,
|
||||
isFetched,
|
||||
isFetching,
|
||||
isLoading,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export { useBookmarkFolder };
|
@ -0,0 +1,25 @@
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { useFeatures } from 'soapbox/hooks/useFeatures';
|
||||
import { bookmarkFolderSchema, type BookmarkFolder } from 'soapbox/schemas/bookmark-folder';
|
||||
|
||||
function useBookmarkFolders() {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<BookmarkFolder>(
|
||||
[Entities.BOOKMARK_FOLDERS],
|
||||
() => api.get('/api/v1/pleroma/bookmark_folders'),
|
||||
{ enabled: features.bookmarkFolders, schema: bookmarkFolderSchema },
|
||||
);
|
||||
|
||||
const bookmarkFolders = entities;
|
||||
|
||||
return {
|
||||
...result,
|
||||
bookmarkFolders,
|
||||
};
|
||||
}
|
||||
|
||||
export { useBookmarkFolders };
|
@ -0,0 +1,31 @@
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useCreateEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { bookmarkFolderSchema } from 'soapbox/schemas/bookmark-folder';
|
||||
|
||||
interface CreateBookmarkFolderParams {
|
||||
name: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
function useCreateBookmarkFolder() {
|
||||
const api = useApi();
|
||||
|
||||
const { createEntity, ...rest } = useCreateEntity(
|
||||
[Entities.BOOKMARK_FOLDERS],
|
||||
(params: CreateBookmarkFolderParams) =>
|
||||
api.post('/api/v1/pleroma/bookmark_folders', params, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}),
|
||||
{ schema: bookmarkFolderSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
createBookmarkFolder: createEntity,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCreateBookmarkFolder };
|
@ -0,0 +1,16 @@
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
|
||||
function useDeleteBookmarkFolder() {
|
||||
const { deleteEntity, isSubmitting } = useEntityActions(
|
||||
[Entities.BOOKMARK_FOLDERS],
|
||||
{ delete: '/api/v1/pleroma/bookmark_folders/:id' },
|
||||
);
|
||||
|
||||
return {
|
||||
deleteBookmarkFolder: deleteEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDeleteBookmarkFolder };
|
@ -0,0 +1,31 @@
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useCreateEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { bookmarkFolderSchema } from 'soapbox/schemas/bookmark-folder';
|
||||
|
||||
interface UpdateBookmarkFolderParams {
|
||||
name: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
function useUpdateBookmarkFolder(folderId: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { createEntity, ...rest } = useCreateEntity(
|
||||
[Entities.BOOKMARK_FOLDERS],
|
||||
(params: UpdateBookmarkFolderParams) =>
|
||||
api.patch(`/api/v1/pleroma/bookmark_folders/${folderId}`, params, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}),
|
||||
{ schema: bookmarkFolderSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
updateBookmarkFolder: createEntity,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useUpdateBookmarkFolder };
|
@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useCreateBookmarkFolder } from 'soapbox/api/hooks';
|
||||
import { Button, Form, HStack, Input } from 'soapbox/components/ui';
|
||||
import { useTextField } from 'soapbox/hooks/forms';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'bookmark_folders.new.title_placeholder', defaultMessage: 'New folder title' },
|
||||
createSuccess: { id: 'bookmark_folders.add.success', defaultMessage: 'Bookmark folder created successfully' },
|
||||
createFail: { id: 'bookmark_folders.add.fail', defaultMessage: 'Failed to create bookmark folder' },
|
||||
});
|
||||
|
||||
const NewFolderForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const name = useTextField();
|
||||
|
||||
const { createBookmarkFolder, isSubmitting } = useCreateBookmarkFolder();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<Element>) => {
|
||||
e.preventDefault();
|
||||
createBookmarkFolder({
|
||||
name: name.value,
|
||||
}, {
|
||||
onSuccess() {
|
||||
toast.success(messages.createSuccess);
|
||||
},
|
||||
onError() {
|
||||
toast.success(messages.createFail);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const label = intl.formatMessage(messages.label);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<HStack space={2}>
|
||||
<label className='grow'>
|
||||
<span style={{ display: 'none' }}>{label}</span>
|
||||
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={label}
|
||||
disabled={isSubmitting}
|
||||
{...name}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
theme='primary'
|
||||
>
|
||||
<FormattedMessage id='bookmark_folders.new.create_title' defaultMessage='Add folder' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewFolderForm;
|
@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { useBookmarkFolders } from 'soapbox/api/hooks';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Column, Emoji, HStack, Icon, Spinner, Stack } from 'soapbox/components/ui';
|
||||
import { useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import NewFolderForm from './components/new-folder-form';
|
||||
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const BookmarkFolders: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
|
||||
const { bookmarkFolders, isFetching } = useBookmarkFolders();
|
||||
|
||||
if (!features.bookmarkFolders) return <Redirect to='/bookmarks/all' />;
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Stack space={4}>
|
||||
<NewFolderForm />
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
to='/bookmarks/all'
|
||||
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>
|
||||
}
|
||||
/>
|
||||
{bookmarkFolders?.map((folder) => (
|
||||
<ListItem
|
||||
key={folder.id}
|
||||
to={`/bookmarks/${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>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkFolders;
|
@ -0,0 +1,162 @@
|
||||
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,
|
||||
}, {
|
||||
onSuccess() {
|
||||
toast.success(intl.formatMessage(messages.editSuccess));
|
||||
dispatch(closeModal('EDIT_BOOKMARK_FOLDER'));
|
||||
},
|
||||
onError() {
|
||||
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;
|
@ -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;
|
@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Pleroma bookmark folder. */
|
||||
const bookmarkFolderSchema = z.object({
|
||||
emoji: z.string().optional().catch(undefined),
|
||||
emoji_url: z.string().optional().catch(undefined),
|
||||
name: z.string().catch(''),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
type BookmarkFolder = z.infer<typeof bookmarkFolderSchema>;
|
||||
|
||||
export { bookmarkFolderSchema, type BookmarkFolder };
|
Loading…
Reference in new issue