1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-24 20:36:40 +07:00

removed upload redux store

This commit is contained in:
Fedor Katurov 2022-01-06 21:04:14 +07:00
parent 140e36b6b7
commit 95b92b643f
38 changed files with 398 additions and 691 deletions

View file

@ -1,12 +1,13 @@
import { api, cleanResult } from '~/utils/api'; import { api, cleanResult } from '~/utils/api';
import { API } from '~/constants/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 = ({ export const apiUploadFile = ({
file, file,
target = 'others', target = UploadTarget.Others,
type = 'image', type = UploadType.Image,
onProgress, onProgress,
}: ApiUploadFileRequest) => { }: ApiUploadFileRequest) => {
const data = new FormData(); const data = new FormData();

11
src/api/uploads/types.ts Normal file
View file

@ -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;

View file

@ -4,7 +4,7 @@ import { append, assocPath, path } from 'ramda';
import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom'; import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import styles from './styles.module.scss'; 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 reduce from 'ramda/es/reduce';
import { AudioPlayer } from '~/components/media/AudioPlayer'; import { AudioPlayer } from '~/components/media/AudioPlayer';
import classnames from 'classnames'; import classnames from 'classnames';
@ -30,12 +30,12 @@ const CommentContent: FC<IProps> = memo(
const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]); const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]); const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]);
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>( const groupped = useMemo<Record<UploadType, IFile[]>>(
() => () =>
reduce( reduce(
(group, file) => (group, file) =>
file.type ? assocPath([file.type], append(file, group[file.type]), group) : group, file.type ? assocPath([file.type], append(file, group[file.type]), group) : group,
{}, {} as Record<UploadType, IFile[]>,
comment.files comment.files
), ),
[comment] [comment]

View file

@ -3,8 +3,7 @@ import { useCommentFormFormik } from '~/hooks/comments/useCommentFormFormik';
import { FormikProvider } from 'formik'; import { FormikProvider } from 'formik';
import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea'; import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { FileUploaderProvider, useFileUploader } from '~/hooks/data/useFileUploader'; import { UploadSubject, UploadTarget } from '~/constants/uploads';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons'; import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons';
import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons'; import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons';
import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches'; import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches';
@ -16,6 +15,9 @@ import styles from './styles.module.scss';
import { ERROR_LITERAL } from '~/constants/errors'; import { ERROR_LITERAL } from '~/constants/errors';
import { useInputPasteUpload } from '~/hooks/dom/useInputPasteUpload'; import { useInputPasteUpload } from '~/hooks/dom/useInputPasteUpload';
import { Filler } from '~/components/containers/Filler'; 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 { interface IProps {
comment?: IComment; comment?: IComment;
@ -24,13 +26,9 @@ interface IProps {
onCancelEdit?: () => void; onCancelEdit?: () => void;
} }
const CommentForm: FC<IProps> = ({ comment, nodeId, saveComment, onCancelEdit }) => { const CommentForm: FC<IProps> = observer(({ comment, nodeId, saveComment, onCancelEdit }) => {
const [textarea, setTextArea] = useState<HTMLTextAreaElement | null>(null); const [textarea, setTextArea] = useState<HTMLTextAreaElement | null>(null);
const uploader = useFileUploader( const uploader = useUploader(UploadSubject.Comment, UploadTarget.Comments, comment?.files);
UPLOAD_SUBJECTS.COMMENT,
UPLOAD_TARGETS.COMMENTS,
comment?.files
);
const formik = useCommentFormFormik( const formik = useCommentFormFormik(
comment || EMPTY_COMMENT, comment || EMPTY_COMMENT,
nodeId, nodeId,
@ -61,7 +59,7 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, saveComment, onCancelEdit })
<UploadDropzone onUpload={uploader.uploadFiles}> <UploadDropzone onUpload={uploader.uploadFiles}>
<form onSubmit={formik.handleSubmit} className={styles.wrap}> <form onSubmit={formik.handleSubmit} className={styles.wrap}>
<FormikProvider value={formik}> <FormikProvider value={formik}>
<FileUploaderProvider value={uploader}> <UploaderContextProvider value={uploader}>
<div className={styles.input}> <div className={styles.input}>
<LocalCommentFormTextarea onPaste={onPaste} ref={setTextArea} /> <LocalCommentFormTextarea onPaste={onPaste} ref={setTextArea} />
@ -110,11 +108,11 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, saveComment, onCancelEdit })
</Button> </Button>
</div> </div>
</div> </div>
</FileUploaderProvider> </UploaderContextProvider>
</FormikProvider> </FormikProvider>
</form> </form>
</UploadDropzone> </UploadDropzone>
); );
}; });
export { CommentForm }; export { CommentForm };

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import { ButtonGroup } from '~/components/input/ButtonGroup'; import { ButtonGroup } from '~/components/input/ButtonGroup';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { COMMENT_FILE_TYPES } from '~/redux/uploads/constants'; import { COMMENT_FILE_TYPES } from '~/constants/uploads';
interface IProps { interface IProps {
onUpload: (files: File[]) => void; onUpload: (files: File[]) => void;

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useMemo } from 'react'; import React, { FC, useCallback } from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { SortableImageGrid } from '~/components/editors/SortableImageGrid'; import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid'; import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
@ -6,68 +6,59 @@ import { IFile } from '~/redux/types';
import { SortEnd } from 'react-sortable-hoc'; import { SortEnd } from 'react-sortable-hoc';
import { moveArrItem } from '~/utils/fn'; import { moveArrItem } from '~/utils/fn';
import { useFileDropZone } from '~/hooks'; import { useFileDropZone } from '~/hooks';
import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants'; import { COMMENT_FILE_TYPES } from '~/constants/uploads';
import { useFileUploaderContext } from '~/hooks/data/useFileUploader'; import { useUploaderContext } from '~/utils/context/UploaderContextProvider';
const CommentFormAttaches: FC = () => { const CommentFormAttaches: FC = () => {
const uploader = useFileUploaderContext(); const {
const { files, pending, setFiles, uploadFiles } = uploader!;
const images = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [
files, files,
]); pendingImages,
pendingAudios,
const pendingImages = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.IMAGE), [ filesAudios,
pending, filesImages,
]); uploadFiles,
setFiles,
const audios = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [ } = useUploaderContext();
files,
]);
const pendingAudios = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.AUDIO), [
pending,
]);
const onDrop = useFileDropZone(uploadFiles, COMMENT_FILE_TYPES); const onDrop = useFileDropZone(uploadFiles, COMMENT_FILE_TYPES);
const hasImageAttaches = images.length > 0 || pendingImages.length > 0; const hasImageAttaches = filesImages.length > 0 || pendingImages.length > 0;
const hasAudioAttaches = audios.length > 0 || pendingAudios.length > 0; const hasAudioAttaches = filesAudios.length > 0 || pendingAudios.length > 0;
const hasAttaches = hasImageAttaches || hasAudioAttaches; const hasAttaches = hasImageAttaches || hasAudioAttaches;
const onImageMove = useCallback( const onImageMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => { ({ oldIndex, newIndex }: SortEnd) => {
setFiles([ setFiles([
...audios, ...filesAudios,
...(moveArrItem( ...(moveArrItem(
oldIndex, oldIndex,
newIndex, newIndex,
images.filter(file => !!file) filesImages.filter(file => !!file)
) as IFile[]), ) as IFile[]),
]); ]);
}, },
[images, audios, setFiles] [setFiles, filesImages, filesAudios]
); );
const onAudioMove = useCallback( const onAudioMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => { ({ oldIndex, newIndex }: SortEnd) => {
setFiles([ setFiles([
...images, ...filesImages,
...(moveArrItem( ...(moveArrItem(
oldIndex, oldIndex,
newIndex, newIndex,
audios.filter(file => !!file) filesAudios.filter(file => !!file)
) as IFile[]), ) as IFile[]),
]); ]);
}, },
[images, audios, setFiles] [setFiles, filesImages, filesAudios]
); );
const onFileDelete = useCallback( const onFileDelete = useCallback(
(fileId: IFile['id']) => { (fileId: IFile['id']) => {
setFiles(files.filter(file => file.id !== fileId)); setFiles(files.filter(file => file.id !== fileId));
}, },
[setFiles, files] [files, setFiles]
); );
const onAudioTitleChange = useCallback( const onAudioTitleChange = useCallback(
@ -90,7 +81,7 @@ const CommentFormAttaches: FC = () => {
onDelete={onFileDelete} onDelete={onFileDelete}
onSortEnd={onImageMove} onSortEnd={onImageMove}
axis="xy" axis="xy"
items={images} items={filesImages}
locked={pendingImages} locked={pendingImages}
pressDelay={50} pressDelay={50}
helperClass={styles.helper} helperClass={styles.helper}
@ -100,7 +91,7 @@ const CommentFormAttaches: FC = () => {
{hasAudioAttaches && ( {hasAudioAttaches && (
<SortableAudioGrid <SortableAudioGrid
items={audios} items={filesAudios}
onDelete={onFileDelete} onDelete={onFileDelete}
onTitleChange={onAudioTitleChange} onTitleChange={onAudioTitleChange}
onSortEnd={onAudioMove} onSortEnd={onAudioMove}

View file

@ -1,5 +1,5 @@
import React, { FC, useCallback, useMemo } from 'react'; import React, { FC, useCallback, useMemo } from 'react';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UploadType } from '~/constants/uploads';
import { ImageGrid } from '../ImageGrid'; import { ImageGrid } from '../ImageGrid';
import { AudioGrid } from '../AudioGrid'; import { AudioGrid } from '../AudioGrid';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
@ -7,25 +7,28 @@ import { NodeEditorProps } from '~/types/node';
import { useNodeImages } from '~/hooks/node/useNodeImages'; import { useNodeImages } from '~/hooks/node/useNodeImages';
import { useNodeAudios } from '~/hooks/node/useNodeAudios'; import { useNodeAudios } from '~/hooks/node/useNodeAudios';
import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik'; import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik';
import { useFileUploaderContext } from '~/hooks/data/useFileUploader';
import { UploadDropzone } from '~/components/upload/UploadDropzone'; import { UploadDropzone } from '~/components/upload/UploadDropzone';
import { useUploaderContext } from '~/utils/context/UploaderContextProvider';
import { values } from 'ramda';
type IProps = NodeEditorProps; type IProps = NodeEditorProps;
const AudioEditor: FC<IProps> = () => { const AudioEditor: FC<IProps> = () => {
const { values } = useNodeFormContext(); const formik = useNodeFormContext();
const { pending, setFiles, uploadFiles } = useFileUploaderContext()!; const { pending, setFiles, uploadFiles } = useUploaderContext()!;
const images = useNodeImages(values); const images = useNodeImages(formik.values);
const audios = useNodeAudios(values); const audios = useNodeAudios(formik.values);
const pendingImages = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.IMAGE), [ const pendingImages = useMemo(
pending, () => values(pending).filter(item => item.type === UploadType.Image),
]); [pending]
);
const pendingAudios = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.AUDIO), [ const pendingAudios = useMemo(
pending, () => values(pending).filter(item => item.type === UploadType.Audio),
]); [pending]
);
const setImages = useCallback(values => setFiles([...values, ...audios]), [setFiles, audios]); const setImages = useCallback(values => setFiles([...values, ...audios]), [setFiles, audios]);

View file

@ -1,16 +1,16 @@
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import { SortEnd } from 'react-sortable-hoc'; import { SortEnd } from 'react-sortable-hoc';
import { IFile } from '~/redux/types'; import { IFile } from '~/redux/types';
import { IUploadStatus } from '~/redux/uploads/reducer';
import { moveArrItem } from '~/utils/fn'; import { moveArrItem } from '~/utils/fn';
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid'; import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { UploadStatus } from '~/store/uploader/UploaderStore';
interface IProps { interface IProps {
files: IFile[]; files: IFile[];
setFiles: (val: IFile[]) => void; setFiles: (val: IFile[]) => void;
locked: IUploadStatus[]; locked: UploadStatus[];
} }
const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => { const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => {

View file

@ -1,6 +1,6 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { EditorUploadButton } from '~/components/editors/EditorUploadButton'; import { EditorUploadButton } from '~/components/editors/EditorUploadButton';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UploadType } from '~/constants/uploads';
import { IEditorComponentProps } from '~/types/node'; import { IEditorComponentProps } from '~/types/node';
type IProps = IEditorComponentProps & {}; type IProps = IEditorComponentProps & {};
@ -9,7 +9,7 @@ const EditorAudioUploadButton: FC<IProps> = () => (
<EditorUploadButton <EditorUploadButton
accept="audio/*" accept="audio/*"
icon="audio" icon="audio"
type={UPLOAD_TYPES.AUDIO} type={UploadType.Audio}
label="Добавить аудио" label="Добавить аудио"
/> />
); );

View file

@ -1,6 +1,6 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { EditorUploadButton } from '~/components/editors/EditorUploadButton'; import { EditorUploadButton } from '~/components/editors/EditorUploadButton';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UploadType } from '~/constants/uploads';
import { IEditorComponentProps } from '~/types/node'; import { IEditorComponentProps } from '~/types/node';
type IProps = IEditorComponentProps & {}; type IProps = IEditorComponentProps & {};
@ -9,7 +9,7 @@ const EditorImageUploadButton: FC<IProps> = () => (
<EditorUploadButton <EditorUploadButton
accept="image/*" accept="image/*"
icon="image" icon="image"
type={UPLOAD_TYPES.IMAGE} type={UploadType.Image}
label="Добавить фоточек" label="Добавить фоточек"
/> />
); );

View file

@ -1,27 +1,27 @@
import React, { ChangeEvent, FC, useCallback } from 'react'; import React, { ChangeEvent, FC, useCallback } from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UploadType } from '~/constants/uploads';
import { IEditorComponentProps } from '~/types/node'; import { IEditorComponentProps } from '~/types/node';
import { useFileUploaderContext } from '~/hooks/data/useFileUploader';
import { getFileType } from '~/utils/uploader'; import { getFileType } from '~/utils/uploader';
import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik'; import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { useUploaderContext } from '~/utils/context/UploaderContextProvider';
type IProps = IEditorComponentProps & { type IProps = IEditorComponentProps & {
accept?: string; accept?: string;
icon?: string; icon?: string;
type?: typeof UPLOAD_TYPES[keyof typeof UPLOAD_TYPES]; type?: UploadType;
label?: string; label?: string;
}; };
const EditorUploadButton: FC<IProps> = ({ const EditorUploadButton: FC<IProps> = ({
accept = 'image/*', accept = 'image/*',
icon = 'plus', icon = 'plus',
type = UPLOAD_TYPES.IMAGE, type = UploadType.Image,
label, label,
}) => { }) => {
const { uploadFiles } = useFileUploaderContext()!; const { uploadFiles } = useUploaderContext()!;
const { values } = useNodeFormContext(); const { values } = useNodeFormContext();
const onInputChange = useCallback( const onInputChange = useCallback(

View file

@ -1,23 +1,26 @@
import React, { ChangeEvent, FC, useCallback, useEffect } from 'react'; import React, { ChangeEvent, FC, useCallback, useEffect } from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UploadSubject, UploadTarget, UploadType } from '~/constants/uploads';
import { path } from 'ramda';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { IEditorComponentProps } from '~/types/node'; import { IEditorComponentProps } from '~/types/node';
import { useFileUploader } from '~/hooks/data/useFileUploader';
import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik'; import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik';
import { getFileType } from '~/utils/uploader'; import { getFileType } from '~/utils/uploader';
import { useUploader } from '~/hooks/data/useUploader';
type IProps = IEditorComponentProps & {}; type IProps = IEditorComponentProps & {};
const EditorUploadCoverButton: FC<IProps> = ({}) => { const EditorUploadCoverButton: FC<IProps> = ({}) => {
const { values, setFieldValue } = useNodeFormContext(); 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 background = values.cover ? getURL(values.cover, PRESETS['300']) : null;
const preview = status && path(['preview'], status); const preview = pendingImages?.[0]?.thumbnail || '';
const onDropCover = useCallback(() => { const onDropCover = useCallback(() => {
setFieldValue('cover', null); setFieldValue('cover', null);
@ -26,7 +29,7 @@ const EditorUploadCoverButton: FC<IProps> = ({}) => {
const onInputChange = useCallback( const onInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => { (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []) const files = Array.from(event.target.files || [])
.filter(file => getFileType(file) === UPLOAD_TYPES.IMAGE) .filter(file => getFileType(file) === UploadType.Image)
.slice(0, 1); .slice(0, 1);
uploadFiles(files); uploadFiles(files);

View file

@ -2,18 +2,19 @@ import React, { FC } from 'react';
import { ImageGrid } from '~/components/editors/ImageGrid'; import { ImageGrid } from '~/components/editors/ImageGrid';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { NodeEditorProps } from '~/types/node'; import { NodeEditorProps } from '~/types/node';
import { useFileUploaderContext } from '~/hooks/data/useFileUploader';
import { UploadDropzone } from '~/components/upload/UploadDropzone'; import { UploadDropzone } from '~/components/upload/UploadDropzone';
import { useUploaderContext } from '~/utils/context/UploaderContextProvider';
import { values } from 'ramda';
type IProps = NodeEditorProps; type IProps = NodeEditorProps;
const ImageEditor: FC<IProps> = () => { const ImageEditor: FC<IProps> = () => {
const { pending, files, setFiles, uploadFiles } = useFileUploaderContext()!; const { pending, files, setFiles, uploadFiles } = useUploaderContext()!;
return ( return (
<UploadDropzone onUpload={uploadFiles} helperClassName={styles.dropzone}> <UploadDropzone onUpload={uploadFiles} helperClassName={styles.dropzone}>
<div className={styles.wrap}> <div className={styles.wrap}>
<ImageGrid files={files} setFiles={setFiles} locked={pending} /> <ImageGrid files={files} setFiles={setFiles} locked={values(pending)} />
</div> </div>
</UploadDropzone> </UploadDropzone>
); );

View file

@ -2,14 +2,14 @@ import React, { FC, useCallback } from 'react';
import { SortEnd } from 'react-sortable-hoc'; import { SortEnd } from 'react-sortable-hoc';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { IFile } from '~/redux/types'; import { IFile } from '~/redux/types';
import { IUploadStatus } from '~/redux/uploads/reducer';
import { moveArrItem } from '~/utils/fn'; import { moveArrItem } from '~/utils/fn';
import { SortableImageGrid } from '~/components/editors/SortableImageGrid'; import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
import { UploadStatus } from '~/store/uploader/UploaderStore';
interface IProps { interface IProps {
files: IFile[]; files: IFile[];
setFiles: (val: IFile[]) => void; setFiles: (val: IFile[]) => void;
locked: IUploadStatus[]; locked: UploadStatus[];
} }
const ImageGrid: FC<IProps> = ({ files, setFiles, locked }) => { const ImageGrid: FC<IProps> = ({ files, setFiles, locked }) => {

View file

@ -4,8 +4,8 @@ import { AudioUpload } from '~/components/upload/AudioUpload';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { SortableAudioGridItem } from '~/components/editors/SortableAudioGridItem'; import { SortableAudioGridItem } from '~/components/editors/SortableAudioGridItem';
import { IFile } from '~/redux/types'; import { IFile } from '~/redux/types';
import { IUploadStatus } from '~/redux/uploads/reducer';
import { AudioPlayer } from '~/components/media/AudioPlayer'; import { AudioPlayer } from '~/components/media/AudioPlayer';
import { UploadStatus } from '~/store/uploader/UploaderStore';
const SortableAudioGrid = SortableContainer( const SortableAudioGrid = SortableContainer(
({ ({
@ -15,7 +15,7 @@ const SortableAudioGrid = SortableContainer(
onTitleChange, onTitleChange,
}: { }: {
items: IFile[]; items: IFile[];
locked: IUploadStatus[]; locked: UploadStatus[];
onDelete: (file_id: IFile['id']) => void; onDelete: (file_id: IFile['id']) => void;
onTitleChange: (file_id: IFile['id'], title: string) => void; onTitleChange: (file_id: IFile['id'], title: string) => void;
}) => { }) => {
@ -35,7 +35,7 @@ const SortableAudioGrid = SortableContainer(
))} ))}
{locked.map((item, index) => ( {locked.map((item, index) => (
<SortableAudioGridItem key={item.temp_id} index={index} collection={1} disabled> <SortableAudioGridItem key={item.id} index={index} collection={1} disabled>
<AudioUpload title={item.name} progress={item.progress} is_uploading /> <AudioUpload title={item.name} progress={item.progress} is_uploading />
</SortableAudioGridItem> </SortableAudioGridItem>
))} ))}

View file

@ -4,10 +4,10 @@ import { ImageUpload } from '~/components/upload/ImageUpload';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { SortableImageGridItem } from '~/components/editors/SortableImageGridItem'; import { SortableImageGridItem } from '~/components/editors/SortableImageGridItem';
import { IFile } from '~/redux/types'; import { IFile } from '~/redux/types';
import { IUploadStatus } from '~/redux/uploads/reducer';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import classNames from 'classnames'; import classNames from 'classnames';
import { UploadStatus } from '~/store/uploader/UploaderStore';
const SortableImageGrid = SortableContainer( const SortableImageGrid = SortableContainer(
({ ({
@ -17,7 +17,7 @@ const SortableImageGrid = SortableContainer(
className, className,
}: { }: {
items: IFile[]; items: IFile[];
locked: IUploadStatus[]; locked: UploadStatus[];
onDelete: (file_id: IFile['id']) => void; onDelete: (file_id: IFile['id']) => void;
size?: number; size?: number;
className?: string; className?: string;
@ -35,8 +35,8 @@ const SortableImageGrid = SortableContainer(
))} ))}
{locked.map((item, index) => ( {locked.map((item, index) => (
<SortableImageGridItem key={item.temp_id} index={index} collection={1} disabled> <SortableImageGridItem key={item.id} index={index} collection={1} disabled>
<ImageUpload thumb={item.preview} progress={item.progress} is_uploading /> <ImageUpload thumb={item.thumbnail} progress={item.progress} is_uploading />
</SortableImageGridItem> </SortableImageGridItem>
))} ))}
</div> </div>

View file

@ -18,7 +18,7 @@
&:global(.is_uploading) { &:global(.is_uploading) {
.thumb { .thumb {
filter: blur(16px); filter: blur(10px);
} }
} }
} }
@ -34,7 +34,6 @@
background: no-repeat 50% 50%; background: no-repeat 50% 50%;
background-size: cover; background-size: cover;
opacity: 1; opacity: 1;
// filter: saturate(0);
} }
.progress { .progress {

View file

@ -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, string[]> = {
[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'];

View file

@ -6,8 +6,7 @@ import { CoverBackdrop } from '~/components/containers/CoverBackdrop';
import { prop } from 'ramda'; import { prop } from 'ramda';
import { useNodeFormFormik } from '~/hooks/node/useNodeFormFormik'; import { useNodeFormFormik } from '~/hooks/node/useNodeFormFormik';
import { EditorButtons } from '~/components/editors/EditorButtons'; import { EditorButtons } from '~/components/editors/EditorButtons';
import { FileUploaderProvider, useFileUploader } from '~/hooks/data/useFileUploader'; import { UploadSubject, UploadTarget } from '~/constants/uploads';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
import { FormikProvider } from 'formik'; import { FormikProvider } from 'formik';
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
import { ModalWrapper } from '~/components/dialogs/ModalWrapper'; import { ModalWrapper } from '~/components/dialogs/ModalWrapper';
@ -15,16 +14,19 @@ import { useTranslatedError } from '~/hooks/data/useTranslatedError';
import { useCloseOnEscape } from '~/hooks'; import { useCloseOnEscape } from '~/hooks';
import { EditorConfirmClose } from '~/components/editors/EditorConfirmClose'; import { EditorConfirmClose } from '~/components/editors/EditorConfirmClose';
import { IDialogProps } from '~/types/modal'; 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 { interface Props extends IDialogProps {
node: INode; node: INode;
onSubmit: (node: INode) => Promise<unknown>; onSubmit: (node: INode) => Promise<unknown>;
} }
const EditorDialog: FC<Props> = ({ node, onRequestClose, onSubmit }) => { const EditorDialog: FC<Props> = observer(({ node, onRequestClose, onSubmit }) => {
const [isConfirmModalShown, setConfirmModalShown] = useState(false); 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 formik = useNodeFormFormik(node, uploader, onRequestClose, onSubmit);
const { values, handleSubmit, dirty, status } = formik; const { values, handleSubmit, dirty, status } = formik;
@ -58,7 +60,7 @@ const EditorDialog: FC<Props> = ({ node, onRequestClose, onSubmit }) => {
return ( return (
<ModalWrapper onOverlayClick={onClose}> <ModalWrapper onOverlayClick={onClose}>
<FileUploaderProvider value={uploader}> <UploaderContextProvider value={uploader}>
<FormikProvider value={formik}> <FormikProvider value={formik}>
<form onSubmit={handleSubmit} className={styles.form}> <form onSubmit={handleSubmit} className={styles.form}>
<BetterScrollDialog <BetterScrollDialog
@ -78,9 +80,9 @@ const EditorDialog: FC<Props> = ({ node, onRequestClose, onSubmit }) => {
</BetterScrollDialog> </BetterScrollDialog>
</form> </form>
</FormikProvider> </FormikProvider>
</FileUploaderProvider> </UploaderContextProvider>
</ModalWrapper> </ModalWrapper>
); );
}; });
export { EditorDialog }; export { EditorDialog };

View file

@ -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 styles from './styles.module.scss';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
import { path, pick } from 'ramda'; import { pick } from 'ramda';
import { selectAuthProfile, selectAuthUser } from '~/redux/auth/selectors'; import { selectAuthProfile, selectAuthUser } from '~/redux/auth/selectors';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { selectUploads } from '~/redux/uploads/selectors'; import { UploadSubject, UploadTarget } from '~/constants/uploads';
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 * as AUTH_ACTIONS from '~/redux/auth/actions'; import * as AUTH_ACTIONS from '~/redux/auth/actions';
import { Icon } from '~/components/input/Icon'; 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 => ({ const mapStateToProps = state => ({
user: pick(['id'], selectAuthUser(state)), user: pick(['id'], selectAuthUser(state)),
profile: pick(['is_loading', 'user'], selectAuthProfile(state)), profile: pick(['is_loading', 'user'], selectAuthProfile(state)),
uploads: pick(['statuses', 'files'], selectUploads(state)),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
authPatchUser: AUTH_ACTIONS.authPatchUser, authPatchUser: AUTH_ACTIONS.authPatchUser,
}; };
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {}; type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
const ProfileAvatarUnconnected: FC<IProps> = ({ const ProfileAvatarUnconnected: FC<IProps> = observer(
user: { id }, ({ user: { id }, profile: { is_loading, user }, authPatchUser }) => {
profile: { is_loading, user }, const uploader = useUploader(
uploads: { statuses, files }, UploadSubject.Avatar,
uploadUploadFiles, UploadTarget.Profiles,
authPatchUser, user?.photo ? [] : []
}) => { );
const can_edit = !is_loading && id && id === user?.id;
const [temp, setTemp] = useState<string>(''); const onInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
try {
if (!event.target.files?.length) {
return;
}
useEffect(() => { const photo = await uploader.uploadFile(event.target.files[0]);
if (!can_edit) return; authPatchUser({ photo });
} catch (error) {
showErrorToast(error);
}
},
[uploader, authPatchUser]
);
Object.entries(statuses).forEach(([id, status]) => { const can_edit = !is_loading && id && id === user?.id;
if (temp === id && !!status.uuid && files[status.uuid]) {
authPatchUser({ photo: files[status.uuid] });
setTemp('');
}
});
}, [statuses, files, temp, can_edit, authPatchUser]);
const onUpload = useCallback( const backgroundImage = is_loading
(uploads: File[]) => { ? undefined
const items: IFileWithUUID[] = Array.from(uploads).map( : `url("${user && getURL(user.photo, PRESETS.avatar)}")`;
(file: File): IFileWithUUID => ({
file,
temp_id: uuid(),
subject: UPLOAD_SUBJECTS.AVATAR,
target: UPLOAD_TARGETS.PROFILES,
type: UPLOAD_TYPES.IMAGE,
})
);
setTemp(path([0, 'temp_id'], items) || ''); return (
uploadUploadFiles(items.slice(0, 1)); <div
}, className={styles.avatar}
[uploadUploadFiles, setTemp] style={{
); backgroundImage,
}}
const onInputChange = useCallback( >
event => { {can_edit && <input type="file" onInput={onInputChange} />}
if (!can_edit) return; {can_edit && (
<div className={styles.can_edit}>
event.preventDefault(); <Icon icon="photo_add" />
</div>
if (!event.target.files || !event.target.files.length) return; )}
</div>
onUpload(Array.from(event.target.files)); );
}, }
[onUpload, can_edit] );
);
const backgroundImage = is_loading
? undefined
: `url("${user && getURL(user.photo, PRESETS.avatar)}")`;
return (
<div
className={styles.avatar}
style={{
backgroundImage,
}}
>
{can_edit && <input type="file" onInput={onInputChange} />}
{can_edit && (
<div className={styles.can_edit}>
<Icon icon="photo_add" />
</div>
)}
</div>
);
};
const ProfileAvatar = connect(mapStateToProps, mapDispatchToProps)(ProfileAvatarUnconnected); const ProfileAvatar = connect(mapStateToProps, mapDispatchToProps)(ProfileAvatarUnconnected);

View file

@ -2,9 +2,9 @@ import { IComment, INode } from '~/redux/types';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { FormikHelpers, useFormik, useFormikContext } from 'formik'; import { FormikHelpers, useFormik, useFormikContext } from 'formik';
import { array, object, string } from 'yup'; import { array, object, string } from 'yup';
import { FileUploader } from '~/hooks/data/useFileUploader';
import { showErrorToast } from '~/utils/errors/showToast'; import { showErrorToast } from '~/utils/errors/showToast';
import { hasPath, path } from 'ramda'; import { hasPath, path } from 'ramda';
import { Uploader } from '~/utils/context/UploaderContextProvider';
const validationSchema = object().shape({ const validationSchema = object().shape({
text: string(), text: string(),
@ -31,7 +31,7 @@ const onSuccess = ({ resetForm, setSubmitting, setErrors }: FormikHelpers<IComme
export const useCommentFormFormik = ( export const useCommentFormFormik = (
values: IComment, values: IComment,
nodeId: INode['id'], nodeId: INode['id'],
uploader: FileUploader, uploader: Uploader,
sendData: (data: IComment) => Promise<unknown>, sendData: (data: IComment) => Promise<unknown>,
stopEditing?: () => void stopEditing?: () => void
) => { ) => {
@ -41,20 +41,19 @@ export const useCommentFormFormik = (
async (values: IComment, helpers: FormikHelpers<IComment>) => { async (values: IComment, helpers: FormikHelpers<IComment>) => {
try { try {
helpers.setSubmitting(true); helpers.setSubmitting(true);
await sendData(values); await sendData({ ...values, files: uploader.files });
onSuccess(helpers)(); onSuccess(helpers)();
} catch (error) { } catch (error) {
onSuccess(helpers)(error); onSuccess(helpers)(error);
} }
}, },
[sendData] [sendData, uploader.files]
); );
const onReset = useCallback(() => { const onReset = useCallback(() => {
uploader.setFiles([]); uploader.setFiles([]);
if (stopEditing) stopEditing(); if (stopEditing) stopEditing();
}, [uploader, stopEditing]); }, [stopEditing, uploader]);
const formik = useFormik({ const formik = useFormik({
initialValues, initialValues,

View file

@ -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<IFile[]>(initialFiles || []);
const [pendingIDs, setPendingIDs] = useState<string[]>([]);
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<typeof useFileUploader>;
const FileUploaderContext = createContext<FileUploader | undefined>(undefined);
export const FileUploaderProvider: FC<{ value: FileUploader; children }> = ({
value,
children,
}) => <FileUploaderContext.Provider value={value}>{children}</FileUploaderContext.Provider>;
export const useFileUploaderContext = () => useContext(FileUploaderContext);

View file

@ -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,
};
};

View file

@ -1,13 +1,13 @@
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UploadType } from '~/constants/uploads';
export const useNodeAudios = (node: INode) => { export const useNodeAudios = (node: INode) => {
if (!node?.files) { if (!node?.files) {
return []; 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, node.files,
]); ]);
}; };

View file

@ -1,10 +1,10 @@
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
import { FileUploader } from '~/hooks/data/useFileUploader';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { FormikConfig, FormikHelpers, useFormik, useFormikContext } from 'formik'; import { FormikConfig, FormikHelpers, useFormik, useFormikContext } from 'formik';
import { object } from 'yup'; import { object } from 'yup';
import { keys } from 'ramda'; import { keys } from 'ramda';
import { showErrorToast } from '~/utils/errors/showToast'; import { showErrorToast } from '~/utils/errors/showToast';
import { Uploader } from '~/utils/context/UploaderContextProvider';
const validationSchema = object().shape({}); const validationSchema = object().shape({});
@ -31,7 +31,7 @@ const afterSubmit = ({ resetForm, setSubmitting, setErrors }: FormikHelpers<INod
export const useNodeFormFormik = ( export const useNodeFormFormik = (
values: INode, values: INode,
uploader: FileUploader, uploader: Uploader,
stopEditing: () => void, stopEditing: () => void,
sendSaveRequest: (node: INode) => Promise<unknown> sendSaveRequest: (node: INode) => Promise<unknown>
) => { ) => {

View file

@ -1,9 +1,9 @@
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UploadType } from '~/constants/uploads';
export const useNodeImages = (node: INode) => { 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, node.files,
]); ]);
}; };

View file

@ -11,9 +11,6 @@ import auth from '~/redux/auth';
import authSaga from '~/redux/auth/sagas'; import authSaga from '~/redux/auth/sagas';
import { IAuthState } from '~/redux/auth/types'; 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 player, { IPlayerState } from '~/redux/player/reducer';
import playerSaga from '~/redux/player/sagas'; import playerSaga from '~/redux/player/sagas';
@ -41,7 +38,6 @@ const playerPersistConfig: PersistConfig = {
export interface IState { export interface IState {
auth: IAuthState; auth: IAuthState;
router: RouterState; router: RouterState;
uploads: IUploadState;
player: IPlayerState; player: IPlayerState;
messages: IMessagesState; messages: IMessagesState;
} }
@ -60,7 +56,6 @@ export const store = createStore(
combineReducers<IState>({ combineReducers<IState>({
auth: persistReducer(authPersistConfig, auth), auth: persistReducer(authPersistConfig, auth),
router: connectRouter(history), router: connectRouter(history),
uploads,
player: persistReducer(playerPersistConfig, player), player: persistReducer(playerPersistConfig, player),
messages, messages,
}), }),
@ -72,7 +67,6 @@ export function configureStore(): {
persistor: Persistor; persistor: Persistor;
} { } {
sagaMiddleware.run(authSaga); sagaMiddleware.run(authSaga);
sagaMiddleware.run(uploadSaga);
sagaMiddleware.run(playerSaga); sagaMiddleware.run(playerSaga);
sagaMiddleware.run(messagesSaga); sagaMiddleware.run(messagesSaga);

View file

@ -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<IUploadStatus>) => ({
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<IUploadStatus>) => ({
temp_id,
status,
type: UPLOAD_ACTIONS.SET_STATUS,
});
export const uploadDropStatus = (temp_id: UUID) => ({
temp_id,
type: UPLOAD_ACTIONS.DROP_STATUS,
});

View file

@ -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<string, IUploadType> = {
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]];

View file

@ -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<typeof uploadAddStatus>
): IUploadState =>
assocPath(
['statuses'],
{ ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status } },
state
);
const dropStatus = (
state: IUploadState,
{ temp_id }: ReturnType<typeof uploadDropStatus>
): IUploadState => assocPath(['statuses'], omit([temp_id], state.statuses), state);
const setStatus = (
state: IUploadState,
{ temp_id, status }: ReturnType<typeof uploadSetStatus>
): IUploadState =>
assocPath(
['statuses'],
{
...state.statuses,
[temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status },
},
state
);
const addFile = (state: IUploadState, { file }: ReturnType<typeof uploadAddFile>): 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,
};

View file

@ -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<UUID, IFile>;
statuses: Record<UUID, IUploadStatus>;
}
const INITIAL_STATE = {
files: {},
statuses: {},
};
export default createReducer(INITIAL_STATE, UPLOAD_HANDLERS);

View file

@ -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<typeof apiUploadFile> = 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<Unwrap<typeof uploadCall>> {
const [promise, chan] = createUploader<Partial<IFileWithUUID>, Partial<IFileWithUUID>>(
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<typeof uploadGetThumb> = 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<typeof uploadCall>,
Unwrap<typeof uploadCancelWorker>
] = 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<typeof uploadUploadFiles>) {
yield all(files.map(file => spawn(uploadFile, file)));
}
export default function*() {
yield takeEvery(UPLOAD_ACTIONS.UPLOAD_FILES, uploadFiles);
}

View file

@ -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;

View file

@ -1,6 +0,0 @@
import { IFile, IFileWithUUID, IUploadProgressHandler } from '~/redux/types';
export type ApiUploadFileRequest = IFileWithUUID & {
onProgress: IUploadProgressHandler;
};
export type ApiUploadFIleResult = IFile;

View file

@ -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<UUID, UploadStatus> = {};
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);
}
}

View file

@ -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<typeof useUploader>;
const UploaderContext = createContext<Uploader>({
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 }) => (
<UploaderContext.Provider value={value}>{children}</UploaderContext.Provider>
);
export const useUploaderContext = () => useContext(UploaderContext);

View file

@ -1,81 +1,24 @@
import uuid from 'uuid4';
import { END, eventChannel, EventChannel } from 'redux-saga';
import { VALIDATORS } from '~/utils/validators'; import { VALIDATORS } from '~/utils/validators';
import { IFile, IResultWithStatus } from '~/redux/types'; import { FILE_MIMES, UploadType } from '~/constants/uploads';
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<T extends {}, R extends {}>(
callback: (args: any) => any,
payload: R
): [
(args: T) => (args: T & { onProgress: (current: number, total: number) => void }) => any,
EventChannel<any>
] {
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];
}
/** if file is image, returns data-uri of thumbnail */
export const uploadGetThumb = async file => { export const uploadGetThumb = async file => {
if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) return ''; if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) return '';
return new Promise<string | ArrayBuffer>(resolve => { return new Promise<string>(resolve => {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => resolve(reader.result || ''); reader.onloadend = () => resolve(reader.result?.toString() || '');
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
}; };
export const fakeUploader = ({ /** returns UploadType by file */
file, export const getFileType = (file: File): UploadType | undefined =>
onProgress, ((file.type &&
mustSucceed, Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) as UploadType) ||
}: {
file: { url?: string; error?: string };
onProgress: (current: number, total: number) => void;
mustSucceed: boolean;
}): Promise<IResultWithStatus<IFile>> => {
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))) ||
undefined; undefined;
// getImageFromPaste returns any images from paste event /** getImageFromPaste returns any images from paste event */
export const getImageFromPaste = (event: ClipboardEvent): Promise<File | undefined> => { export const getImageFromPaste = (event: ClipboardEvent): Promise<File | undefined> => {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;

View file

@ -1,5 +1,5 @@
import isValid from 'date-fns/isValid'; 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 => const isValidEmail = (email: string): boolean =>
!!email && !!email &&