diff --git a/src/redux/uploads/api.ts b/src/api/uploads/index.ts similarity index 65% rename from src/redux/uploads/api.ts rename to src/api/uploads/index.ts index 756ac8ac..bea6e88f 100644 --- a/src/redux/uploads/api.ts +++ b/src/api/uploads/index.ts @@ -1,12 +1,13 @@ import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; -import { ApiUploadFileRequest, ApiUploadFIleResult } from '~/redux/uploads/types'; +import { ApiUploadFileRequest, ApiUploadFIleResult } from '~/api/uploads/types'; +import { UploadTarget, UploadType } from '~/constants/uploads'; export const apiUploadFile = ({ file, - target = 'others', - type = 'image', + target = UploadTarget.Others, + type = UploadType.Image, onProgress, }: ApiUploadFileRequest) => { const data = new FormData(); diff --git a/src/api/uploads/types.ts b/src/api/uploads/types.ts new file mode 100644 index 00000000..0b6337de --- /dev/null +++ b/src/api/uploads/types.ts @@ -0,0 +1,11 @@ +import { IFile, IUploadProgressHandler } from '~/redux/types'; +import { UploadTarget, UploadType } from '~/constants/uploads'; + +export type ApiUploadFileRequest = { + file: File; + type: UploadType; + target: UploadTarget; + onProgress: IUploadProgressHandler; +}; + +export type ApiUploadFIleResult = IFile; diff --git a/src/components/comment/CommentContent/index.tsx b/src/components/comment/CommentContent/index.tsx index be44c235..9d279c2f 100644 --- a/src/components/comment/CommentContent/index.tsx +++ b/src/components/comment/CommentContent/index.tsx @@ -4,7 +4,7 @@ import { append, assocPath, path } from 'ramda'; import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom'; import { Group } from '~/components/containers/Group'; import styles from './styles.module.scss'; -import { UPLOAD_TYPES } from '~/redux/uploads/constants'; +import { UploadType } from '~/constants/uploads'; import reduce from 'ramda/es/reduce'; import { AudioPlayer } from '~/components/media/AudioPlayer'; import classnames from 'classnames'; @@ -30,12 +30,12 @@ const CommentContent: FC = memo( const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]); const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]); - const groupped = useMemo>( + const groupped = useMemo>( () => reduce( (group, file) => file.type ? assocPath([file.type], append(file, group[file.type]), group) : group, - {}, + {} as Record, comment.files ), [comment] diff --git a/src/components/comment/CommentForm/index.tsx b/src/components/comment/CommentForm/index.tsx index d149d8f1..1f99ca41 100644 --- a/src/components/comment/CommentForm/index.tsx +++ b/src/components/comment/CommentForm/index.tsx @@ -3,8 +3,7 @@ import { useCommentFormFormik } from '~/hooks/comments/useCommentFormFormik'; import { FormikProvider } from 'formik'; import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea'; import { Button } from '~/components/input/Button'; -import { FileUploaderProvider, useFileUploader } from '~/hooks/data/useFileUploader'; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants'; +import { UploadSubject, UploadTarget } from '~/constants/uploads'; import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons'; import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons'; import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches'; @@ -16,6 +15,9 @@ import styles from './styles.module.scss'; import { ERROR_LITERAL } from '~/constants/errors'; import { useInputPasteUpload } from '~/hooks/dom/useInputPasteUpload'; import { Filler } from '~/components/containers/Filler'; +import { useUploader } from '~/hooks/data/useUploader'; +import { UploaderContextProvider } from '~/utils/context/UploaderContextProvider'; +import { observer } from 'mobx-react-lite'; interface IProps { comment?: IComment; @@ -24,13 +26,9 @@ interface IProps { onCancelEdit?: () => void; } -const CommentForm: FC = ({ comment, nodeId, saveComment, onCancelEdit }) => { +const CommentForm: FC = observer(({ comment, nodeId, saveComment, onCancelEdit }) => { const [textarea, setTextArea] = useState(null); - const uploader = useFileUploader( - UPLOAD_SUBJECTS.COMMENT, - UPLOAD_TARGETS.COMMENTS, - comment?.files - ); + const uploader = useUploader(UploadSubject.Comment, UploadTarget.Comments, comment?.files); const formik = useCommentFormFormik( comment || EMPTY_COMMENT, nodeId, @@ -61,7 +59,7 @@ const CommentForm: FC = ({ comment, nodeId, saveComment, onCancelEdit })
- +
@@ -110,11 +108,11 @@ const CommentForm: FC = ({ comment, nodeId, saveComment, onCancelEdit })
-
+
); -}; +}); export { CommentForm }; diff --git a/src/components/comment/CommentFormAttachButtons/index.tsx b/src/components/comment/CommentFormAttachButtons/index.tsx index 97eb6a03..59534254 100644 --- a/src/components/comment/CommentFormAttachButtons/index.tsx +++ b/src/components/comment/CommentFormAttachButtons/index.tsx @@ -1,7 +1,7 @@ import React, { FC, useCallback } from 'react'; import { ButtonGroup } from '~/components/input/ButtonGroup'; import { Button } from '~/components/input/Button'; -import { COMMENT_FILE_TYPES } from '~/redux/uploads/constants'; +import { COMMENT_FILE_TYPES } from '~/constants/uploads'; interface IProps { onUpload: (files: File[]) => void; diff --git a/src/components/comment/CommentFormAttaches/index.tsx b/src/components/comment/CommentFormAttaches/index.tsx index 52d970d3..af05d4f7 100644 --- a/src/components/comment/CommentFormAttaches/index.tsx +++ b/src/components/comment/CommentFormAttaches/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useMemo } from 'react'; +import React, { FC, useCallback } from 'react'; import styles from './styles.module.scss'; import { SortableImageGrid } from '~/components/editors/SortableImageGrid'; import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid'; @@ -6,68 +6,59 @@ import { IFile } from '~/redux/types'; import { SortEnd } from 'react-sortable-hoc'; import { moveArrItem } from '~/utils/fn'; import { useFileDropZone } from '~/hooks'; -import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants'; -import { useFileUploaderContext } from '~/hooks/data/useFileUploader'; +import { COMMENT_FILE_TYPES } from '~/constants/uploads'; +import { useUploaderContext } from '~/utils/context/UploaderContextProvider'; const CommentFormAttaches: FC = () => { - const uploader = useFileUploaderContext(); - const { files, pending, setFiles, uploadFiles } = uploader!; - - const images = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [ + const { files, - ]); - - const pendingImages = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.IMAGE), [ - pending, - ]); - - const audios = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [ - files, - ]); - - const pendingAudios = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.AUDIO), [ - pending, - ]); + pendingImages, + pendingAudios, + filesAudios, + filesImages, + uploadFiles, + setFiles, + } = useUploaderContext(); const onDrop = useFileDropZone(uploadFiles, COMMENT_FILE_TYPES); - const hasImageAttaches = images.length > 0 || pendingImages.length > 0; - const hasAudioAttaches = audios.length > 0 || pendingAudios.length > 0; + const hasImageAttaches = filesImages.length > 0 || pendingImages.length > 0; + const hasAudioAttaches = filesAudios.length > 0 || pendingAudios.length > 0; const hasAttaches = hasImageAttaches || hasAudioAttaches; const onImageMove = useCallback( ({ oldIndex, newIndex }: SortEnd) => { setFiles([ - ...audios, + ...filesAudios, ...(moveArrItem( oldIndex, newIndex, - images.filter(file => !!file) + filesImages.filter(file => !!file) ) as IFile[]), ]); }, - [images, audios, setFiles] + [setFiles, filesImages, filesAudios] ); const onAudioMove = useCallback( ({ oldIndex, newIndex }: SortEnd) => { setFiles([ - ...images, + ...filesImages, ...(moveArrItem( oldIndex, newIndex, - audios.filter(file => !!file) + filesAudios.filter(file => !!file) ) as IFile[]), ]); }, - [images, audios, setFiles] + [setFiles, filesImages, filesAudios] ); const onFileDelete = useCallback( (fileId: IFile['id']) => { setFiles(files.filter(file => file.id !== fileId)); }, - [setFiles, files] + [files, setFiles] ); const onAudioTitleChange = useCallback( @@ -90,7 +81,7 @@ const CommentFormAttaches: FC = () => { onDelete={onFileDelete} onSortEnd={onImageMove} axis="xy" - items={images} + items={filesImages} locked={pendingImages} pressDelay={50} helperClass={styles.helper} @@ -100,7 +91,7 @@ const CommentFormAttaches: FC = () => { {hasAudioAttaches && ( = () => { - const { values } = useNodeFormContext(); - const { pending, setFiles, uploadFiles } = useFileUploaderContext()!; + const formik = useNodeFormContext(); + const { pending, setFiles, uploadFiles } = useUploaderContext()!; - const images = useNodeImages(values); - const audios = useNodeAudios(values); + const images = useNodeImages(formik.values); + const audios = useNodeAudios(formik.values); - const pendingImages = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.IMAGE), [ - pending, - ]); + const pendingImages = useMemo( + () => values(pending).filter(item => item.type === UploadType.Image), + [pending] + ); - const pendingAudios = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.AUDIO), [ - pending, - ]); + const pendingAudios = useMemo( + () => values(pending).filter(item => item.type === UploadType.Audio), + [pending] + ); const setImages = useCallback(values => setFiles([...values, ...audios]), [setFiles, audios]); diff --git a/src/components/editors/AudioGrid/index.tsx b/src/components/editors/AudioGrid/index.tsx index d94c521b..6b716a4c 100644 --- a/src/components/editors/AudioGrid/index.tsx +++ b/src/components/editors/AudioGrid/index.tsx @@ -1,16 +1,16 @@ import React, { FC, useCallback } from 'react'; import { SortEnd } from 'react-sortable-hoc'; import { IFile } from '~/redux/types'; -import { IUploadStatus } from '~/redux/uploads/reducer'; import { moveArrItem } from '~/utils/fn'; import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid'; import styles from './styles.module.scss'; +import { UploadStatus } from '~/store/uploader/UploaderStore'; interface IProps { files: IFile[]; setFiles: (val: IFile[]) => void; - locked: IUploadStatus[]; + locked: UploadStatus[]; } const AudioGrid: FC = ({ files, setFiles, locked }) => { diff --git a/src/components/editors/EditorAudioUploadButton/index.tsx b/src/components/editors/EditorAudioUploadButton/index.tsx index c0c759ad..786a7576 100644 --- a/src/components/editors/EditorAudioUploadButton/index.tsx +++ b/src/components/editors/EditorAudioUploadButton/index.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import { EditorUploadButton } from '~/components/editors/EditorUploadButton'; -import { UPLOAD_TYPES } from '~/redux/uploads/constants'; +import { UploadType } from '~/constants/uploads'; import { IEditorComponentProps } from '~/types/node'; type IProps = IEditorComponentProps & {}; @@ -9,7 +9,7 @@ const EditorAudioUploadButton: FC = () => ( ); diff --git a/src/components/editors/EditorImageUploadButton/index.tsx b/src/components/editors/EditorImageUploadButton/index.tsx index fea764d2..67f07d2d 100644 --- a/src/components/editors/EditorImageUploadButton/index.tsx +++ b/src/components/editors/EditorImageUploadButton/index.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import { EditorUploadButton } from '~/components/editors/EditorUploadButton'; -import { UPLOAD_TYPES } from '~/redux/uploads/constants'; +import { UploadType } from '~/constants/uploads'; import { IEditorComponentProps } from '~/types/node'; type IProps = IEditorComponentProps & {}; @@ -9,7 +9,7 @@ const EditorImageUploadButton: FC = () => ( ); diff --git a/src/components/editors/EditorUploadButton/index.tsx b/src/components/editors/EditorUploadButton/index.tsx index 96852330..e0f1f5c4 100644 --- a/src/components/editors/EditorUploadButton/index.tsx +++ b/src/components/editors/EditorUploadButton/index.tsx @@ -1,27 +1,27 @@ import React, { ChangeEvent, FC, useCallback } from 'react'; import styles from './styles.module.scss'; import { Icon } from '~/components/input/Icon'; -import { UPLOAD_TYPES } from '~/redux/uploads/constants'; +import { UploadType } from '~/constants/uploads'; import { IEditorComponentProps } from '~/types/node'; -import { useFileUploaderContext } from '~/hooks/data/useFileUploader'; import { getFileType } from '~/utils/uploader'; import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik'; import { Button } from '~/components/input/Button'; +import { useUploaderContext } from '~/utils/context/UploaderContextProvider'; type IProps = IEditorComponentProps & { accept?: string; icon?: string; - type?: typeof UPLOAD_TYPES[keyof typeof UPLOAD_TYPES]; + type?: UploadType; label?: string; }; const EditorUploadButton: FC = ({ accept = 'image/*', icon = 'plus', - type = UPLOAD_TYPES.IMAGE, + type = UploadType.Image, label, }) => { - const { uploadFiles } = useFileUploaderContext()!; + const { uploadFiles } = useUploaderContext()!; const { values } = useNodeFormContext(); const onInputChange = useCallback( diff --git a/src/components/editors/EditorUploadCoverButton/index.tsx b/src/components/editors/EditorUploadCoverButton/index.tsx index 9f1d43b8..2b31901e 100644 --- a/src/components/editors/EditorUploadCoverButton/index.tsx +++ b/src/components/editors/EditorUploadCoverButton/index.tsx @@ -1,23 +1,26 @@ import React, { ChangeEvent, FC, useCallback, useEffect } from 'react'; import styles from './styles.module.scss'; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants'; -import { path } from 'ramda'; +import { UploadSubject, UploadTarget, UploadType } from '~/constants/uploads'; import { getURL } from '~/utils/dom'; import { Icon } from '~/components/input/Icon'; import { PRESETS } from '~/constants/urls'; import { IEditorComponentProps } from '~/types/node'; -import { useFileUploader } from '~/hooks/data/useFileUploader'; import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik'; import { getFileType } from '~/utils/uploader'; +import { useUploader } from '~/hooks/data/useUploader'; type IProps = IEditorComponentProps & {}; const EditorUploadCoverButton: FC = ({}) => { const { values, setFieldValue } = useNodeFormContext(); - const { uploadFiles, files } = useFileUploader(UPLOAD_SUBJECTS.EDITOR, UPLOAD_TARGETS.NODES, []); + const { uploadFiles, files, pendingImages } = useUploader( + UploadSubject.Editor, + UploadTarget.Nodes, + [] + ); const background = values.cover ? getURL(values.cover, PRESETS['300']) : null; - const preview = status && path(['preview'], status); + const preview = pendingImages?.[0]?.thumbnail || ''; const onDropCover = useCallback(() => { setFieldValue('cover', null); @@ -26,7 +29,7 @@ const EditorUploadCoverButton: FC = ({}) => { const onInputChange = useCallback( (event: ChangeEvent) => { const files = Array.from(event.target.files || []) - .filter(file => getFileType(file) === UPLOAD_TYPES.IMAGE) + .filter(file => getFileType(file) === UploadType.Image) .slice(0, 1); uploadFiles(files); diff --git a/src/components/editors/ImageEditor/index.tsx b/src/components/editors/ImageEditor/index.tsx index 00cce5a6..2d3eb0be 100644 --- a/src/components/editors/ImageEditor/index.tsx +++ b/src/components/editors/ImageEditor/index.tsx @@ -2,18 +2,19 @@ import React, { FC } from 'react'; import { ImageGrid } from '~/components/editors/ImageGrid'; import styles from './styles.module.scss'; import { NodeEditorProps } from '~/types/node'; -import { useFileUploaderContext } from '~/hooks/data/useFileUploader'; import { UploadDropzone } from '~/components/upload/UploadDropzone'; +import { useUploaderContext } from '~/utils/context/UploaderContextProvider'; +import { values } from 'ramda'; type IProps = NodeEditorProps; const ImageEditor: FC = () => { - const { pending, files, setFiles, uploadFiles } = useFileUploaderContext()!; + const { pending, files, setFiles, uploadFiles } = useUploaderContext()!; return (
- +
); diff --git a/src/components/editors/ImageGrid/index.tsx b/src/components/editors/ImageGrid/index.tsx index 702702db..fa181953 100644 --- a/src/components/editors/ImageGrid/index.tsx +++ b/src/components/editors/ImageGrid/index.tsx @@ -2,14 +2,14 @@ import React, { FC, useCallback } from 'react'; import { SortEnd } from 'react-sortable-hoc'; import styles from './styles.module.scss'; import { IFile } from '~/redux/types'; -import { IUploadStatus } from '~/redux/uploads/reducer'; import { moveArrItem } from '~/utils/fn'; import { SortableImageGrid } from '~/components/editors/SortableImageGrid'; +import { UploadStatus } from '~/store/uploader/UploaderStore'; interface IProps { files: IFile[]; setFiles: (val: IFile[]) => void; - locked: IUploadStatus[]; + locked: UploadStatus[]; } const ImageGrid: FC = ({ files, setFiles, locked }) => { diff --git a/src/components/editors/SortableAudioGrid/index.tsx b/src/components/editors/SortableAudioGrid/index.tsx index fc1bf578..9af0cc0b 100644 --- a/src/components/editors/SortableAudioGrid/index.tsx +++ b/src/components/editors/SortableAudioGrid/index.tsx @@ -4,8 +4,8 @@ import { AudioUpload } from '~/components/upload/AudioUpload'; import styles from './styles.module.scss'; import { SortableAudioGridItem } from '~/components/editors/SortableAudioGridItem'; import { IFile } from '~/redux/types'; -import { IUploadStatus } from '~/redux/uploads/reducer'; import { AudioPlayer } from '~/components/media/AudioPlayer'; +import { UploadStatus } from '~/store/uploader/UploaderStore'; const SortableAudioGrid = SortableContainer( ({ @@ -15,7 +15,7 @@ const SortableAudioGrid = SortableContainer( onTitleChange, }: { items: IFile[]; - locked: IUploadStatus[]; + locked: UploadStatus[]; onDelete: (file_id: IFile['id']) => void; onTitleChange: (file_id: IFile['id'], title: string) => void; }) => { @@ -35,7 +35,7 @@ const SortableAudioGrid = SortableContainer( ))} {locked.map((item, index) => ( - + ))} diff --git a/src/components/editors/SortableImageGrid/index.tsx b/src/components/editors/SortableImageGrid/index.tsx index a46ced14..52bcec4e 100644 --- a/src/components/editors/SortableImageGrid/index.tsx +++ b/src/components/editors/SortableImageGrid/index.tsx @@ -4,10 +4,10 @@ import { ImageUpload } from '~/components/upload/ImageUpload'; import styles from './styles.module.scss'; import { SortableImageGridItem } from '~/components/editors/SortableImageGridItem'; import { IFile } from '~/redux/types'; -import { IUploadStatus } from '~/redux/uploads/reducer'; import { getURL } from '~/utils/dom'; import { PRESETS } from '~/constants/urls'; import classNames from 'classnames'; +import { UploadStatus } from '~/store/uploader/UploaderStore'; const SortableImageGrid = SortableContainer( ({ @@ -17,7 +17,7 @@ const SortableImageGrid = SortableContainer( className, }: { items: IFile[]; - locked: IUploadStatus[]; + locked: UploadStatus[]; onDelete: (file_id: IFile['id']) => void; size?: number; className?: string; @@ -35,8 +35,8 @@ const SortableImageGrid = SortableContainer( ))} {locked.map((item, index) => ( - - + + ))} diff --git a/src/components/upload/ImageUpload/styles.module.scss b/src/components/upload/ImageUpload/styles.module.scss index 922f6254..91f3d768 100644 --- a/src/components/upload/ImageUpload/styles.module.scss +++ b/src/components/upload/ImageUpload/styles.module.scss @@ -18,7 +18,7 @@ &:global(.is_uploading) { .thumb { - filter: blur(16px); + filter: blur(10px); } } } @@ -34,7 +34,6 @@ background: no-repeat 50% 50%; background-size: cover; opacity: 1; - // filter: saturate(0); } .progress { diff --git a/src/constants/uploads/index.ts b/src/constants/uploads/index.ts new file mode 100644 index 00000000..0d683b89 --- /dev/null +++ b/src/constants/uploads/index.ts @@ -0,0 +1,51 @@ +import { IFile } from '~/redux/types'; + +export const EMPTY_FILE: IFile = { + id: undefined, + user_id: undefined, + node_id: undefined, + + name: '', + orig_name: '', + path: '', + full_path: '', + url: '', + size: 0, + type: undefined, + mime: '', +}; + +// for targeted cancellation +export enum UploadSubject { + Editor = 'editor', + Comment = 'comment', + Avatar = 'avatar', +} + +export enum UploadTarget { + Nodes = 'nodes', + Comments = 'comments', + Profiles = 'profiles', + Others = 'other', +} + +export enum UploadType { + Image = 'image', + Audio = 'audio', + Video = 'video', + Other = 'other', +} + +export const FILE_MIMES: Record = { + [UploadType.Video]: [], + [UploadType.Image]: ['image/jpeg', 'image/jpg', 'image/png'], + [UploadType.Audio]: ['audio/mpeg3', 'audio/mpeg', 'audio/mp3'], + [UploadType.Other]: [], +}; + +export const COMMENT_FILE_TYPES = [ + ...FILE_MIMES[UploadType.Image], + ...FILE_MIMES[UploadType.Audio], +]; + +export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg']; diff --git a/src/containers/dialogs/EditorDialog/index.tsx b/src/containers/dialogs/EditorDialog/index.tsx index 7558dc6a..36649557 100644 --- a/src/containers/dialogs/EditorDialog/index.tsx +++ b/src/containers/dialogs/EditorDialog/index.tsx @@ -6,8 +6,7 @@ import { CoverBackdrop } from '~/components/containers/CoverBackdrop'; import { prop } from 'ramda'; import { useNodeFormFormik } from '~/hooks/node/useNodeFormFormik'; import { EditorButtons } from '~/components/editors/EditorButtons'; -import { FileUploaderProvider, useFileUploader } from '~/hooks/data/useFileUploader'; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants'; +import { UploadSubject, UploadTarget } from '~/constants/uploads'; import { FormikProvider } from 'formik'; import { INode } from '~/redux/types'; import { ModalWrapper } from '~/components/dialogs/ModalWrapper'; @@ -15,16 +14,19 @@ import { useTranslatedError } from '~/hooks/data/useTranslatedError'; import { useCloseOnEscape } from '~/hooks'; import { EditorConfirmClose } from '~/components/editors/EditorConfirmClose'; import { IDialogProps } from '~/types/modal'; +import { useUploader } from '~/hooks/data/useUploader'; +import { UploaderContextProvider } from '~/utils/context/UploaderContextProvider'; +import { observer } from 'mobx-react-lite'; interface Props extends IDialogProps { node: INode; onSubmit: (node: INode) => Promise; } -const EditorDialog: FC = ({ node, onRequestClose, onSubmit }) => { +const EditorDialog: FC = observer(({ node, onRequestClose, onSubmit }) => { const [isConfirmModalShown, setConfirmModalShown] = useState(false); - const uploader = useFileUploader(UPLOAD_SUBJECTS.EDITOR, UPLOAD_TARGETS.NODES, node.files); + const uploader = useUploader(UploadSubject.Editor, UploadTarget.Nodes, node.files); const formik = useNodeFormFormik(node, uploader, onRequestClose, onSubmit); const { values, handleSubmit, dirty, status } = formik; @@ -58,7 +60,7 @@ const EditorDialog: FC = ({ node, onRequestClose, onSubmit }) => { return ( - +
= ({ node, onRequestClose, onSubmit }) => {
-
+
); -}; +}); export { EditorDialog }; diff --git a/src/containers/profile/ProfileAvatar/index.tsx b/src/containers/profile/ProfileAvatar/index.tsx index 0fb2ccd8..014d9cf3 100644 --- a/src/containers/profile/ProfileAvatar/index.tsx +++ b/src/containers/profile/ProfileAvatar/index.tsx @@ -1,104 +1,75 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { ChangeEvent, FC, useCallback } from 'react'; import styles from './styles.module.scss'; import { connect } from 'react-redux'; import { getURL } from '~/utils/dom'; -import { path, pick } from 'ramda'; +import { pick } from 'ramda'; import { selectAuthProfile, selectAuthUser } from '~/redux/auth/selectors'; import { PRESETS } from '~/constants/urls'; -import { selectUploads } from '~/redux/uploads/selectors'; -import { IFileWithUUID } from '~/redux/types'; -import uuid from 'uuid4'; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants'; -import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; +import { UploadSubject, UploadTarget } from '~/constants/uploads'; import * as AUTH_ACTIONS from '~/redux/auth/actions'; import { Icon } from '~/components/input/Icon'; +import { useUploader } from '~/hooks/data/useUploader'; +import { observer } from 'mobx-react-lite'; +import { showErrorToast } from '~/utils/errors/showToast'; const mapStateToProps = state => ({ user: pick(['id'], selectAuthUser(state)), profile: pick(['is_loading', 'user'], selectAuthProfile(state)), - uploads: pick(['statuses', 'files'], selectUploads(state)), }); const mapDispatchToProps = { - uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, authPatchUser: AUTH_ACTIONS.authPatchUser, }; type IProps = ReturnType & typeof mapDispatchToProps & {}; -const ProfileAvatarUnconnected: FC = ({ - user: { id }, - profile: { is_loading, user }, - uploads: { statuses, files }, - uploadUploadFiles, - authPatchUser, -}) => { - const can_edit = !is_loading && id && id === user?.id; +const ProfileAvatarUnconnected: FC = observer( + ({ user: { id }, profile: { is_loading, user }, authPatchUser }) => { + const uploader = useUploader( + UploadSubject.Avatar, + UploadTarget.Profiles, + user?.photo ? [] : [] + ); - const [temp, setTemp] = useState(''); + const onInputChange = useCallback( + async (event: ChangeEvent) => { + try { + if (!event.target.files?.length) { + return; + } - useEffect(() => { - if (!can_edit) return; + const photo = await uploader.uploadFile(event.target.files[0]); + authPatchUser({ photo }); + } catch (error) { + showErrorToast(error); + } + }, + [uploader, authPatchUser] + ); - Object.entries(statuses).forEach(([id, status]) => { - if (temp === id && !!status.uuid && files[status.uuid]) { - authPatchUser({ photo: files[status.uuid] }); - setTemp(''); - } - }); - }, [statuses, files, temp, can_edit, authPatchUser]); + const can_edit = !is_loading && id && id === user?.id; - const onUpload = useCallback( - (uploads: File[]) => { - const items: IFileWithUUID[] = Array.from(uploads).map( - (file: File): IFileWithUUID => ({ - file, - temp_id: uuid(), - subject: UPLOAD_SUBJECTS.AVATAR, - target: UPLOAD_TARGETS.PROFILES, - type: UPLOAD_TYPES.IMAGE, - }) - ); + const backgroundImage = is_loading + ? undefined + : `url("${user && getURL(user.photo, PRESETS.avatar)}")`; - setTemp(path([0, 'temp_id'], items) || ''); - uploadUploadFiles(items.slice(0, 1)); - }, - [uploadUploadFiles, setTemp] - ); - - const onInputChange = useCallback( - event => { - if (!can_edit) return; - - event.preventDefault(); - - if (!event.target.files || !event.target.files.length) return; - - onUpload(Array.from(event.target.files)); - }, - [onUpload, can_edit] - ); - - const backgroundImage = is_loading - ? undefined - : `url("${user && getURL(user.photo, PRESETS.avatar)}")`; - - return ( -
- {can_edit && } - {can_edit && ( -
- -
- )} -
- ); -}; + return ( +
+ {can_edit && } + {can_edit && ( +
+ +
+ )} +
+ ); + } +); const ProfileAvatar = connect(mapStateToProps, mapDispatchToProps)(ProfileAvatarUnconnected); diff --git a/src/hooks/comments/useCommentFormFormik.ts b/src/hooks/comments/useCommentFormFormik.ts index 3bb869a5..797b0107 100644 --- a/src/hooks/comments/useCommentFormFormik.ts +++ b/src/hooks/comments/useCommentFormFormik.ts @@ -2,9 +2,9 @@ import { IComment, INode } from '~/redux/types'; import { useCallback, useEffect, useRef } from 'react'; import { FormikHelpers, useFormik, useFormikContext } from 'formik'; import { array, object, string } from 'yup'; -import { FileUploader } from '~/hooks/data/useFileUploader'; import { showErrorToast } from '~/utils/errors/showToast'; import { hasPath, path } from 'ramda'; +import { Uploader } from '~/utils/context/UploaderContextProvider'; const validationSchema = object().shape({ text: string(), @@ -31,7 +31,7 @@ const onSuccess = ({ resetForm, setSubmitting, setErrors }: FormikHelpers Promise, stopEditing?: () => void ) => { @@ -41,20 +41,19 @@ export const useCommentFormFormik = ( async (values: IComment, helpers: FormikHelpers) => { try { helpers.setSubmitting(true); - await sendData(values); + await sendData({ ...values, files: uploader.files }); onSuccess(helpers)(); } catch (error) { onSuccess(helpers)(error); } }, - [sendData] + [sendData, uploader.files] ); const onReset = useCallback(() => { uploader.setFiles([]); - if (stopEditing) stopEditing(); - }, [uploader, stopEditing]); + }, [stopEditing, uploader]); const formik = useFormik({ initialValues, diff --git a/src/hooks/data/useFileUploader.tsx b/src/hooks/data/useFileUploader.tsx deleted file mode 100644 index 79179f88..00000000 --- a/src/hooks/data/useFileUploader.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { - createContext, - FC, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import { IFile, IFileWithUUID } from '~/redux/types'; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants'; -import { getFileType } from '~/utils/uploader'; -import uuid from 'uuid4'; -import { useDispatch } from 'react-redux'; -import { uploadUploadFiles } from '~/redux/uploads/actions'; -import { useShallowSelect } from '~/hooks/data/useShallowSelect'; -import { selectUploads } from '~/redux/uploads/selectors'; -import { path } from 'ramda'; -import { IUploadStatus } from '~/redux/uploads/reducer'; - -export const useFileUploader = ( - subject: typeof UPLOAD_SUBJECTS[keyof typeof UPLOAD_SUBJECTS], - target: typeof UPLOAD_TARGETS[keyof typeof UPLOAD_TARGETS], - initialFiles?: IFile[] -) => { - const dispatch = useDispatch(); - const { files: uploadedFiles, statuses } = useShallowSelect(selectUploads); - - const [files, setFiles] = useState(initialFiles || []); - const [pendingIDs, setPendingIDs] = useState([]); - - const uploadFiles = useCallback( - (files: File[]) => { - const items: IFileWithUUID[] = files.map( - (file: File): IFileWithUUID => ({ - file, - temp_id: uuid(), - subject, - target, - type: getFileType(file), - }) - ); - - const temps = items.filter(el => !!el.temp_id).map(file => file.temp_id!); - - setPendingIDs([...pendingIDs, ...temps]); - dispatch(uploadUploadFiles(items)); - }, - [pendingIDs, setPendingIDs, dispatch, subject, target] - ); - - useEffect(() => { - const added = pendingIDs - .map(temp_uuid => path([temp_uuid, 'uuid'], statuses) as IUploadStatus['uuid']) - .filter(el => el) - .map(el => (path([String(el)], uploadedFiles) as IFile) || undefined) - .filter(el => !!el! && !files.some(file => file && file.id === el.id)); - - const newPending = pendingIDs.filter( - temp_id => - statuses[temp_id] && - (!statuses[temp_id].uuid || !added.some(file => file.id === statuses[temp_id].uuid)) - ); - - if (added.length) { - setPendingIDs(newPending); - setFiles([...files, ...added]); - } - }, [statuses, files, pendingIDs, setFiles, setPendingIDs, uploadedFiles]); - - const pending = useMemo(() => pendingIDs.map(id => statuses[id]).filter(el => !!el), [ - statuses, - pendingIDs, - ]); - - const isLoading = pending.length > 0; - - return { uploadFiles, pending, files, setFiles, isUploading: isLoading }; -}; - -export type FileUploader = ReturnType; -const FileUploaderContext = createContext(undefined); - -export const FileUploaderProvider: FC<{ value: FileUploader; children }> = ({ - value, - children, -}) => {children}; - -export const useFileUploaderContext = () => useContext(FileUploaderContext); diff --git a/src/hooks/data/useUploader.ts b/src/hooks/data/useUploader.ts new file mode 100644 index 00000000..88a3017b --- /dev/null +++ b/src/hooks/data/useUploader.ts @@ -0,0 +1,63 @@ +import { UploadSubject, UploadTarget } from '~/constants/uploads'; +import { IFile } from '~/redux/types'; +import { useCallback } from 'react'; +import { apiUploadFile } from '~/api/uploads'; +import { keys } from 'ramda'; +import { useLocalObservable } from 'mobx-react-lite'; +import { UploaderStore } from '~/store/uploader/UploaderStore'; +import { showErrorToast } from '~/utils/errors/showToast'; +import uuid from 'uuid4'; + +export const useUploader = ( + subject: UploadSubject, + target: UploadTarget, + initialFiles?: IFile[] +) => { + const store = useLocalObservable(() => new UploaderStore(initialFiles)); + + const uploadFile = useCallback( + async (file: File) => { + const id = uuid(); + + try { + // TODO: pass CancelationToken for axios as cancel() to pending + // TODO: cancel all uploads on unmount + + const pending = await store.addPending(id, file); + const onProgress = ({ loaded, total }) => store.updateProgress(id, loaded, total); + const result = await apiUploadFile({ file, target, type: pending.type, onProgress }); + + store.removePending(id); + store.addFile(result); + + return result; + } catch (error) { + store.removePending(id); + showErrorToast(error); + } + }, + [store, target] + ); + + const uploadFiles = useCallback( + async (files: File[]) => { + await Promise.any(files.map(file => uploadFile(file))); + }, + [uploadFile] + ); + + const isUploading = keys(store.pending).length > 0; + + return { + uploadFile, + uploadFiles, + files: store.files, + filesImages: store.filesImages, + filesAudios: store.filesAudios, + pending: store.pending, + pendingImages: store.pendingImages, + pendingAudios: store.pendingAudios, + isUploading, + setFiles: store.setFiles, + }; +}; diff --git a/src/hooks/node/useNodeAudios.ts b/src/hooks/node/useNodeAudios.ts index 5dc043a1..90ccd298 100644 --- a/src/hooks/node/useNodeAudios.ts +++ b/src/hooks/node/useNodeAudios.ts @@ -1,13 +1,13 @@ import { INode } from '~/redux/types'; import { useMemo } from 'react'; -import { UPLOAD_TYPES } from '~/redux/uploads/constants'; +import { UploadType } from '~/constants/uploads'; export const useNodeAudios = (node: INode) => { if (!node?.files) { return []; } - return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [ + return useMemo(() => node.files.filter(file => file && file.type === UploadType.Audio), [ node.files, ]); }; diff --git a/src/hooks/node/useNodeFormFormik.ts b/src/hooks/node/useNodeFormFormik.ts index 0312f67a..800fbddc 100644 --- a/src/hooks/node/useNodeFormFormik.ts +++ b/src/hooks/node/useNodeFormFormik.ts @@ -1,10 +1,10 @@ import { INode } from '~/redux/types'; -import { FileUploader } from '~/hooks/data/useFileUploader'; import { useCallback, useRef } from 'react'; import { FormikConfig, FormikHelpers, useFormik, useFormikContext } from 'formik'; import { object } from 'yup'; import { keys } from 'ramda'; import { showErrorToast } from '~/utils/errors/showToast'; +import { Uploader } from '~/utils/context/UploaderContextProvider'; const validationSchema = object().shape({}); @@ -31,7 +31,7 @@ const afterSubmit = ({ resetForm, setSubmitting, setErrors }: FormikHelpers void, sendSaveRequest: (node: INode) => Promise ) => { diff --git a/src/hooks/node/useNodeImages.ts b/src/hooks/node/useNodeImages.ts index 4f6b71d5..21a41daf 100644 --- a/src/hooks/node/useNodeImages.ts +++ b/src/hooks/node/useNodeImages.ts @@ -1,9 +1,9 @@ import { INode } from '~/redux/types'; import { useMemo } from 'react'; -import { UPLOAD_TYPES } from '~/redux/uploads/constants'; +import { UploadType } from '~/constants/uploads'; export const useNodeImages = (node: INode) => { - return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [ + return useMemo(() => node.files.filter(file => file && file.type === UploadType.Image), [ node.files, ]); }; diff --git a/src/redux/store.ts b/src/redux/store.ts index c4402e9d..17eb9266 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -11,9 +11,6 @@ import auth from '~/redux/auth'; import authSaga from '~/redux/auth/sagas'; import { IAuthState } from '~/redux/auth/types'; -import uploads, { IUploadState } from '~/redux/uploads/reducer'; -import uploadSaga from '~/redux/uploads/sagas'; - import player, { IPlayerState } from '~/redux/player/reducer'; import playerSaga from '~/redux/player/sagas'; @@ -41,7 +38,6 @@ const playerPersistConfig: PersistConfig = { export interface IState { auth: IAuthState; router: RouterState; - uploads: IUploadState; player: IPlayerState; messages: IMessagesState; } @@ -60,7 +56,6 @@ export const store = createStore( combineReducers({ auth: persistReducer(authPersistConfig, auth), router: connectRouter(history), - uploads, player: persistReducer(playerPersistConfig, player), messages, }), @@ -72,7 +67,6 @@ export function configureStore(): { persistor: Persistor; } { sagaMiddleware.run(authSaga); - sagaMiddleware.run(uploadSaga); sagaMiddleware.run(playerSaga); sagaMiddleware.run(messagesSaga); diff --git a/src/redux/uploads/actions.ts b/src/redux/uploads/actions.ts deleted file mode 100644 index 62663ca5..00000000 --- a/src/redux/uploads/actions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { UPLOAD_ACTIONS } from '~/redux/uploads/constants'; -import { IFile, IFileWithUUID, UUID } from '../types'; -import { IUploadStatus } from './reducer'; - -export const uploadUploadFiles = (files: IFileWithUUID[]) => ({ - files, - type: UPLOAD_ACTIONS.UPLOAD_FILES, -}); - -export const uploadAddStatus = (temp_id: UUID, status?: Partial) => ({ - temp_id, - status, - type: UPLOAD_ACTIONS.ADD_STATUS, -}); - -export const uploadAddFile = (file: IFile) => ({ - file, - type: UPLOAD_ACTIONS.ADD_FILE, -}); - -export const uploadSetStatus = (temp_id: UUID, status?: Partial) => ({ - temp_id, - status, - type: UPLOAD_ACTIONS.SET_STATUS, -}); - -export const uploadDropStatus = (temp_id: UUID) => ({ - temp_id, - type: UPLOAD_ACTIONS.DROP_STATUS, -}); diff --git a/src/redux/uploads/constants.ts b/src/redux/uploads/constants.ts deleted file mode 100644 index f5280bd4..00000000 --- a/src/redux/uploads/constants.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { IFile, IUploadType } from '~/redux/types'; -import { IUploadStatus } from './reducer'; - -const prefix = 'UPLOAD.'; - -export const UPLOAD_ACTIONS = { - UPLOAD_FILES: `${prefix}UPLOAD_FILES`, - UPLOAD_CANCEL: `${prefix}UPLOAD_CANCEL`, - - ADD_STATUS: `${prefix}ADD_STATUS`, - DROP_STATUS: `${prefix}DROP_STATUS`, - SET_STATUS: `${prefix}SET_STATUS`, - - ADD_FILE: `${prefix}ADD_FILE`, -}; - -export const EMPTY_FILE: IFile = { - id: undefined, - user_id: undefined, - node_id: undefined, - - name: '', - orig_name: '', - path: '', - full_path: '', - url: '', - size: 0, - type: undefined, - mime: '', -}; - -export const EMPTY_UPLOAD_STATUS: IUploadStatus = { - is_uploading: false, - preview: '', - error: '', - uuid: 0, - url: '', - progress: 0, - thumbnail_url: '', - type: '', - temp_id: '', - name: '', -}; - -// for targeted cancellation -export const UPLOAD_SUBJECTS = { - EDITOR: 'editor', - COMMENT: 'comment', - AVATAR: 'avatar', -}; - -export const UPLOAD_TARGETS = { - NODES: 'nodes', - COMMENTS: 'comments', - PROFILES: 'profiles', - OTHER: 'other', -}; - -export const UPLOAD_TYPES: Record = { - IMAGE: 'image', - AUDIO: 'audio', - VIDEO: 'video', - OTHER: 'other', -}; - -export const FILE_MIMES = { - [UPLOAD_TYPES.VIDEO]: [], - [UPLOAD_TYPES.IMAGE]: ['image/jpeg', 'image/jpg', 'image/png'], - [UPLOAD_TYPES.AUDIO]: ['audio/mpeg3', 'audio/mpeg', 'audio/mp3'], - [UPLOAD_TYPES.OTHER]: [], -}; - -export const COMMENT_FILE_TYPES = [ - ...FILE_MIMES[UPLOAD_TYPES.IMAGE], - ...FILE_MIMES[UPLOAD_TYPES.AUDIO], -]; - -export const IMAGE_FILE_TYPES = [...FILE_MIMES[UPLOAD_TYPES.IMAGE]]; diff --git a/src/redux/uploads/handlers.ts b/src/redux/uploads/handlers.ts deleted file mode 100644 index 2dc5bb4d..00000000 --- a/src/redux/uploads/handlers.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { assocPath, omit } from 'ramda'; - -import { EMPTY_UPLOAD_STATUS, UPLOAD_ACTIONS } from './constants'; -import { uploadAddFile, uploadAddStatus, uploadDropStatus, uploadSetStatus } from './actions'; -import { IUploadState } from './reducer'; - -const addStatus = ( - state: IUploadState, - { temp_id, status }: ReturnType -): IUploadState => - assocPath( - ['statuses'], - { ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status } }, - state - ); - -const dropStatus = ( - state: IUploadState, - { temp_id }: ReturnType -): IUploadState => assocPath(['statuses'], omit([temp_id], state.statuses), state); - -const setStatus = ( - state: IUploadState, - { temp_id, status }: ReturnType -): IUploadState => - assocPath( - ['statuses'], - { - ...state.statuses, - [temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status }, - }, - state - ); - -const addFile = (state: IUploadState, { file }: ReturnType): IUploadState => { - if (!file.id) return state; - return assocPath(['files', file.id], file, state); -}; - -export const UPLOAD_HANDLERS = { - [UPLOAD_ACTIONS.ADD_STATUS]: addStatus, - [UPLOAD_ACTIONS.DROP_STATUS]: dropStatus, - [UPLOAD_ACTIONS.SET_STATUS]: setStatus, - [UPLOAD_ACTIONS.ADD_FILE]: addFile, -}; diff --git a/src/redux/uploads/reducer.ts b/src/redux/uploads/reducer.ts deleted file mode 100644 index 38a5d310..00000000 --- a/src/redux/uploads/reducer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createReducer } from '~/utils/reducer'; -import { IFile, UUID } from '~/redux/types'; -import { UPLOAD_HANDLERS } from './handlers'; - -export interface IUploadStatus { - is_uploading: boolean; - error: string; - preview: string; - uuid: IFile['id']; - url: string; - type: string; - thumbnail_url: string; - progress: number; - temp_id: UUID; - name: string; -} - -export interface IUploadState { - files: Record; - statuses: Record; -} - -const INITIAL_STATE = { - files: {}, - statuses: {}, -}; - -export default createReducer(INITIAL_STATE, UPLOAD_HANDLERS); diff --git a/src/redux/uploads/sagas.ts b/src/redux/uploads/sagas.ts deleted file mode 100644 index 480bcf9a..00000000 --- a/src/redux/uploads/sagas.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { SagaIterator } from 'redux-saga'; -import { all, call, fork, put, race, spawn, take, takeEvery } from 'redux-saga/effects'; -import { apiUploadFile } from './api'; -import { FILE_MIMES, UPLOAD_ACTIONS } from '~/redux/uploads/constants'; -import { - uploadAddFile, - uploadAddStatus, - uploadDropStatus, - uploadSetStatus, - uploadUploadFiles, -} from './actions'; -import { createUploader, uploadGetThumb } from '~/utils/uploader'; -import { HTTP_RESPONSES } from '~/utils/api'; -import { IFileWithUUID, IUploadProgressHandler, Unwrap } from '../types'; - -function* uploadCall({ - file, - temp_id, - target, - type, - onProgress, -}: IFileWithUUID & { onProgress: IUploadProgressHandler }) { - const data: Unwrap = yield call(apiUploadFile, { - file, - temp_id, - type, - target, - onProgress, - }); - - return data; -} - -function* onUploadProgress(chan) { - while (true) { - const { progress, temp_id }: { progress: number; temp_id: string } = yield take(chan); - - yield put(uploadSetStatus(temp_id, { progress })); - } -} - -function* uploadCancelWorker(id) { - while (true) { - const { temp_id } = yield take(UPLOAD_ACTIONS.UPLOAD_CANCEL); - if (temp_id === id) break; - } - - return true; -} - -function* uploadWorker({ - file, - temp_id, - target, - type, -}: IFileWithUUID): SagaIterator> { - const [promise, chan] = createUploader, Partial>( - uploadCall, - { temp_id, target, type } - ); - - yield fork(onUploadProgress, chan); - - return yield call(promise, { - temp_id, - file, - target, - type, - }); -} - -function* uploadFile({ file, temp_id, type, target, onSuccess, onFail }: IFileWithUUID) { - if (!temp_id) return; - - try { - if (!file.type || !type || !FILE_MIMES[type] || !FILE_MIMES[type].includes(file.type)) { - return { - error: 'File_Not_Image', - status: HTTP_RESPONSES.BAD_REQUEST, - data: {}, - }; - } - - const preview: Unwrap = yield call(uploadGetThumb, file); - - yield put( - uploadAddStatus(temp_id, { - preview: preview.toString(), - is_uploading: true, - temp_id, - type, - name: file.name, - }) - ); - - const [result, cancel]: [ - Unwrap, - Unwrap - ] = yield race([ - call(uploadWorker, { - file, - temp_id, - target, - type, - }), - call(uploadCancelWorker, temp_id), - ]); - - if (cancel || !result) { - if (onFail) onFail(); - return yield put(uploadDropStatus(temp_id)); - } - - yield put( - uploadSetStatus(temp_id, { - is_uploading: false, - error: '', - uuid: result.id, - url: result.full_path, - type, - thumbnail_url: result.full_path, - progress: 1, - name: file.name, - }) - ); - - yield put(uploadAddFile(result)); - - if (onSuccess) onSuccess(result); - } catch (error) { - if (onFail) onFail(); - - return yield put( - uploadSetStatus(temp_id, { - is_uploading: false, - error: error.message, - type, - }) - ); - } -} - -function* uploadFiles({ files }: ReturnType) { - yield all(files.map(file => spawn(uploadFile, file))); -} - -export default function*() { - yield takeEvery(UPLOAD_ACTIONS.UPLOAD_FILES, uploadFiles); -} diff --git a/src/redux/uploads/selectors.ts b/src/redux/uploads/selectors.ts deleted file mode 100644 index 80e4c5e2..00000000 --- a/src/redux/uploads/selectors.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { IState } from '~/redux/store'; -import { IUploadState } from '~/redux/uploads/reducer'; - -export const selectUploads = ({ uploads }: IState): IUploadState => uploads; -export const selectUploadStatuses = ({ uploads: { statuses } }: IState): IUploadState['statuses'] => statuses; diff --git a/src/redux/uploads/types.ts b/src/redux/uploads/types.ts deleted file mode 100644 index 410acb22..00000000 --- a/src/redux/uploads/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IFile, IFileWithUUID, IUploadProgressHandler } from '~/redux/types'; - -export type ApiUploadFileRequest = IFileWithUUID & { - onProgress: IUploadProgressHandler; -}; -export type ApiUploadFIleResult = IFile; diff --git a/src/store/uploader/UploaderStore.ts b/src/store/uploader/UploaderStore.ts new file mode 100644 index 00000000..25f4bf12 --- /dev/null +++ b/src/store/uploader/UploaderStore.ts @@ -0,0 +1,79 @@ +import { makeAutoObservable, runInAction } from 'mobx'; +import { IFile, UUID } from '~/redux/types'; +import { getFileType, uploadGetThumb } from '~/utils/uploader'; +import { has, omit, values } from 'ramda'; +import { UploadType } from '~/constants/uploads'; +import { ERROR_LITERAL, ERRORS } from '~/constants/errors'; + +export interface UploadStatus { + id: UUID; + thumbnail: string; + size: number; + name: string; + progress: number; + type: UploadType; +} + +export class UploaderStore { + pending: Record = {}; + + constructor(public files: IFile[] = []) { + makeAutoObservable(this); + } + + addFile = (file: IFile) => this.files.push(file); + setFiles = (files: IFile[]) => (this.files = files); + + /** adds pending from file */ + addPending = async (id: string, file: File) => { + const thumbnail = await uploadGetThumb(file); + const size = file.size; + const name = file.name; + const progress = 0; + const type = getFileType(file); + + if (!type) { + throw new Error(ERROR_LITERAL[ERRORS.UNKNOWN_FILE_TYPE]); + } + + runInAction(() => { + this.pending[id] = { id, thumbnail, size, name, progress, type }; + }); + + return this.pending[id]; + }; + + /** updates progress for file */ + updateProgress = (id: UUID, loaded: number, total: number) => { + if (!has(id, this.pending)) { + return; + } + + this.pending[id].progress = loaded / total; + }; + + /** removes pending item by id */ + removePending = (id: UUID) => { + this.pending = omit([id], this.pending); + }; + + /** returns only image files */ + get filesImages() { + return this.files.filter(file => file && file.type === UploadType.Image); + } + + /** returns only image pending */ + get pendingImages() { + return values(this.pending).filter(item => item.type === UploadType.Image); + } + + /** returns only audio files */ + get filesAudios() { + return this.files.filter(file => file && file.type === UploadType.Audio); + } + + /** returns only audio pending */ + get pendingAudios() { + return values(this.pending).filter(item => item.type === UploadType.Audio); + } +} diff --git a/src/utils/context/UploaderContextProvider.tsx b/src/utils/context/UploaderContextProvider.tsx new file mode 100644 index 00000000..52e8d06a --- /dev/null +++ b/src/utils/context/UploaderContextProvider.tsx @@ -0,0 +1,28 @@ +import React, { createContext, FC, useContext } from 'react'; +import { useUploader } from '~/hooks/data/useUploader'; +import { IFile } from '~/redux/types'; +import { EMPTY_FILE } from '~/constants/uploads'; + +export type Uploader = ReturnType; + +const UploaderContext = createContext({ + files: [], + filesAudios: [], + filesImages: [], + uploadFile: async () => EMPTY_FILE, + uploadFiles: async () => {}, + pending: {}, + pendingAudios: [], + pendingImages: [], + isUploading: false, + setFiles: (files: IFile[]) => files, +}); + +export const UploaderContextProvider: FC<{ + value: Uploader; + children; +}> = ({ value, children }) => ( + {children} +); + +export const useUploaderContext = () => useContext(UploaderContext); diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts index 3ade4cf8..ff7e79f3 100644 --- a/src/utils/uploader.ts +++ b/src/utils/uploader.ts @@ -1,81 +1,24 @@ -import uuid from 'uuid4'; -import { END, eventChannel, EventChannel } from 'redux-saga'; import { VALIDATORS } from '~/utils/validators'; -import { IFile, IResultWithStatus } from '~/redux/types'; -import { HTTP_RESPONSES } from './api'; -import { EMPTY_FILE, FILE_MIMES, UPLOAD_TYPES } from '~/redux/uploads/constants'; - -export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg']; - -export function createUploader( - callback: (args: any) => any, - payload: R -): [ - (args: T) => (args: T & { onProgress: (current: number, total: number) => void }) => any, - EventChannel -] { - let emit; - - const chan = eventChannel(emitter => { - emit = emitter; - return () => null; - }); - - const onProgress = ({ loaded, total }: { loaded: number; total: number }): void => { - emit(loaded >= total ? END : { ...payload, progress: parseFloat((loaded / total).toFixed(1)) }); - }; - - const wrappedCallback = args => callback({ ...args, onProgress }); - - return [wrappedCallback, chan]; -} +import { FILE_MIMES, UploadType } from '~/constants/uploads'; +/** if file is image, returns data-uri of thumbnail */ export const uploadGetThumb = async file => { if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) return ''; - return new Promise(resolve => { + return new Promise(resolve => { const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result || ''); + reader.onloadend = () => resolve(reader.result?.toString() || ''); reader.readAsDataURL(file); }); }; -export const fakeUploader = ({ - file, - onProgress, - mustSucceed, -}: { - file: { url?: string; error?: string }; - onProgress: (current: number, total: number) => void; - mustSucceed: boolean; -}): Promise> => { - const { error } = file; - - return new Promise((resolve, reject) => { - setTimeout(() => { - onProgress(1, 3); - }, 1000); - - setTimeout(() => { - onProgress(2, 3); - }, 2000); - - setTimeout(() => { - onProgress(3, 3); - if (mustSucceed) { - resolve({ status: HTTP_RESPONSES.CREATED, data: { ...EMPTY_FILE, id: uuid() } }); - } else { - reject({ response: { statusText: error } }); - } - }, 3000); - }); -}; - -export const getFileType = (file: File): keyof typeof UPLOAD_TYPES | undefined => - (file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) || +/** returns UploadType by file */ +export const getFileType = (file: File): UploadType | undefined => + ((file.type && + Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) as UploadType) || undefined; -// getImageFromPaste returns any images from paste event +/** getImageFromPaste returns any images from paste event */ export const getImageFromPaste = (event: ClipboardEvent): Promise => { const items = event.clipboardData?.items; diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 0691546f..dd06ee00 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -1,5 +1,5 @@ import isValid from 'date-fns/isValid'; -import { IMAGE_MIME_TYPES } from '~/utils/uploader'; +import { IMAGE_MIME_TYPES } from '~/constants/uploads'; const isValidEmail = (email: string): boolean => !!email &&