diff --git a/src/components/editors/AudioGrid/index.tsx b/src/components/editors/AudioGrid/index.tsx index c3ca845e..40593006 100644 --- a/src/components/editors/AudioGrid/index.tsx +++ b/src/components/editors/AudioGrid/index.tsx @@ -28,9 +28,21 @@ const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => { [setFiles, files] ); + const onTitleChange = useCallback( + (changeId: IFile['id'], title: IFile['metadata']['title']) => { + setFiles( + files.map(file => + file && file.id === changeId ? { ...file, metadata: { ...file.metadata, title } } : file + ) + ); + }, + [setFiles, files] + ); + return ( <SortableAudioGrid onDrop={onDrop} + onTitleChange={onTitleChange} onSortEnd={onMove} axis="xy" items={files} diff --git a/src/components/editors/SortableAudioGrid/index.tsx b/src/components/editors/SortableAudioGrid/index.tsx index ff5f2a30..ae9228d5 100644 --- a/src/components/editors/SortableAudioGrid/index.tsx +++ b/src/components/editors/SortableAudioGrid/index.tsx @@ -12,17 +12,19 @@ const SortableAudioGrid = SortableContainer( items, locked, onDrop, + onTitleChange, }: { items: IFile[]; locked: IUploadStatus[]; onDrop: (file_id: IFile['id']) => void; + onTitleChange: (file_id: IFile['id'], title: IFile['metadata']['title']) => void; }) => ( <div className={styles.grid}> {items .filter(file => file && file.id) .map((file, index) => ( <SortableImageGridItem key={file.id} index={index} collection={0}> - <AudioPlayer file={file} onDrop={onDrop} nonInteractive /> + <AudioPlayer file={file} onDrop={onDrop} onTitleChange={onTitleChange} isEditing /> </SortableImageGridItem> ))} diff --git a/src/components/media/AudioPlayer/index.tsx b/src/components/media/AudioPlayer/index.tsx index 9ea11463..00b13c5b 100644 --- a/src/components/media/AudioPlayer/index.tsx +++ b/src/components/media/AudioPlayer/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect, memo } from 'react'; +import React, { useCallback, useState, useEffect, memo, useMemo } from 'react'; import { connect } from 'react-redux'; import { selectPlayer } from '~/redux/player/selectors'; import * as PLAYER_ACTIONS from '~/redux/player/actions'; @@ -8,6 +8,7 @@ import { Player, IPlayerProgress } from '~/utils/player'; import classNames from 'classnames'; import * as styles from './styles.scss'; import { Icon } from '~/components/input/Icon'; +import { InputText } from '~/components/input/InputText'; const mapStateToProps = state => ({ player: selectPlayer(state), @@ -23,15 +24,17 @@ const mapDispatchToProps = { type Props = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & { file: IFile; - nonInteractive?: boolean; + isEditing?: boolean; onDrop?: (id: IFile['id']) => void; + onTitleChange?: (file_id: IFile['id'], title: IFile['metadata']['title']) => void; }; const AudioPlayerUnconnected = memo( ({ file, onDrop, - nonInteractive, + isEditing, + onTitleChange, player: { file: current, status }, playerSetFileAndPlay, playerPlay, @@ -46,7 +49,7 @@ const AudioPlayerUnconnected = memo( }); const onPlay = useCallback(() => { - if (nonInteractive) return; + if (isEditing) return; if (current && current.id === file.id) { if (status === PLAYER_STATES.PLAYING) return playerPause(); @@ -54,7 +57,7 @@ const AudioPlayerUnconnected = memo( } playerSetFileAndPlay(file); - }, [file, current, status, playerPlay, playerPause, playerSetFileAndPlay, nonInteractive]); + }, [file, current, status, playerPlay, playerPause, playerSetFileAndPlay, isEditing]); const onProgress = useCallback( ({ detail }: { detail: IPlayerProgress }) => { @@ -80,6 +83,21 @@ const AudioPlayerUnconnected = memo( onDrop(file.id); }, [file, onDrop]); + const title = useMemo( + () => + (file.metadata && + (file.metadata.title || + [file.metadata.id3artist, file.metadata.id3title].filter(el => el).join(' - '))) || + file.orig_name || + '', + [file.metadata] + ); + + const onRename = useCallback((val: string) => onTitleChange(file.id, val), [ + onTitleChange, + file.id, + ]); + useEffect(() => { const active = current && current.id === file.id; setPlaying(current && current.id === file.id); @@ -91,11 +109,6 @@ const AudioPlayerUnconnected = memo( }; }, [file, current, setPlaying, onProgress]); - const title = - file.metadata && - (file.metadata.title || - [file.metadata.id3artist, file.metadata.id3title].filter(el => !!el).join(' - ')); - return ( <div onClick={onPlay} className={classNames(styles.wrap, { playing })}> {onDrop && ( @@ -112,13 +125,21 @@ const AudioPlayerUnconnected = memo( )} </div> - <div className={styles.content}> - <div className={styles.title}>{title || 'Unknown'}</div> - - <div className={styles.progress} onClick={onSeek}> - <div className={styles.bar} style={{ width: `${progress.progress}%` }} /> + {isEditing ? ( + <div className={styles.input}> + <InputText value={title} handler={onRename} /> </div> - </div> + ) : ( + <div className={styles.content}> + <div className={styles.title}> + <div className={styles.title}>{title || 'Unknown'}</div> + </div> + + <div className={styles.progress} onClick={onSeek}> + <div className={styles.bar} style={{ width: `${progress.progress}%` }} /> + </div> + </div> + )} </div> ); } diff --git a/src/components/media/AudioPlayer/styles.scss b/src/components/media/AudioPlayer/styles.scss index dae743ff..ee5d160a 100644 --- a/src/components/media/AudioPlayer/styles.scss +++ b/src/components/media/AudioPlayer/styles.scss @@ -130,3 +130,9 @@ height: 20px; } } + +.input { + flex: 1; + box-sizing: border-box; + padding: 0 48px 0 0; +} diff --git a/src/components/node/CommentForm/index.tsx b/src/components/node/CommentForm/index.tsx index 75c4ce86..3a355a7c 100644 --- a/src/components/node/CommentForm/index.tsx +++ b/src/components/node/CommentForm/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, KeyboardEventHandler, useEffect, useMemo } from 'react'; +import React, { FC, useCallback, KeyboardEventHandler, useEffect, useMemo, memo } from 'react'; import { Textarea } from '~/components/input/Textarea'; import * as styles from './styles.scss'; import { Filler } from '~/components/containers/Filler'; @@ -41,244 +41,263 @@ type IProps = ReturnType<typeof mapStateToProps> & is_before?: boolean; }; -const CommentFormUnconnected: FC<IProps> = ({ - node: { comment_data, is_sending_comment }, - uploads: { statuses, files }, - id, - is_before = false, - nodePostComment, - nodeSetCommentData, - uploadUploadFiles, - nodeCancelCommentEdit, -}) => { - const onInputChange = useCallback( - event => { - event.preventDefault(); +const CommentFormUnconnected: FC<IProps> = memo( + ({ + node: { comment_data, is_sending_comment }, + uploads: { statuses, files }, + id, + is_before = false, + nodePostComment, + nodeSetCommentData, + uploadUploadFiles, + nodeCancelCommentEdit, + }) => { + const onInputChange = useCallback( + event => { + event.preventDefault(); - if (!event.target.files || !event.target.files.length) return; + if (!event.target.files || !event.target.files.length) return; - const items: IFileWithUUID[] = Array.from(event.target.files).map( - (file: File): IFileWithUUID => ({ - file, - temp_id: uuid(), - subject: UPLOAD_SUBJECTS.COMMENT, - target: UPLOAD_TARGETS.COMMENTS, - type: getFileType(file), - }) - ); + const items: IFileWithUUID[] = Array.from(event.target.files).map( + (file: File): IFileWithUUID => ({ + file, + temp_id: uuid(), + subject: UPLOAD_SUBJECTS.COMMENT, + target: UPLOAD_TARGETS.COMMENTS, + type: getFileType(file), + }) + ); - const temps = items.map(file => file.temp_id); + const temps = items.map(file => file.temp_id); - nodeSetCommentData( - id, - assocPath(['temp_ids'], [...comment_data[id].temp_ids, ...temps], comment_data[id]) - ); - uploadUploadFiles(items); - }, - [uploadUploadFiles, comment_data, id, nodeSetCommentData] - ); - - const onInput = useCallback<InputHandler>( - text => { - nodeSetCommentData(id, assocPath(['text'], text, comment_data[id])); - }, - [nodeSetCommentData, comment_data, id] - ); - - useEffect(() => { - const temp_ids = (comment_data && comment_data[id] && comment_data[id].temp_ids) || []; - const added_files = temp_ids - .map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid) - .map(el => !!el && files[el]) - .filter(el => !!el && !comment_data[id].files.some(file => file && file.id === el.id)); - - const filtered_temps = temp_ids.filter( - temp_id => - statuses[temp_id] && - (!statuses[temp_id].uuid || !added_files.some(file => file.id === statuses[temp_id].uuid)) + nodeSetCommentData( + id, + assocPath(['temp_ids'], [...comment_data[id].temp_ids, ...temps], comment_data[id]) + ); + uploadUploadFiles(items); + }, + [uploadUploadFiles, comment_data, id, nodeSetCommentData] ); - if (added_files.length) { - nodeSetCommentData(id, { - ...comment_data[id], - temp_ids: filtered_temps, - files: [...comment_data[id].files, ...added_files], - }); - } - }, [statuses, files]); + const onInput = useCallback<InputHandler>( + text => { + nodeSetCommentData(id, assocPath(['text'], text, comment_data[id])); + }, + [nodeSetCommentData, comment_data, id] + ); - const comment = comment_data[id]; + useEffect(() => { + const temp_ids = (comment_data && comment_data[id] && comment_data[id].temp_ids) || []; + const added_files = temp_ids + .map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid) + .map(el => !!el && files[el]) + .filter(el => !!el && !comment_data[id].files.some(file => file && file.id === el.id)); - const is_uploading_files = useMemo(() => comment.temp_ids.length > 0, [comment.temp_ids]); - - const onSubmit = useCallback( - event => { - if (event) event.preventDefault(); - if (is_uploading_files || is_sending_comment) return; - - nodePostComment(id, is_before); - }, - [nodePostComment, id, is_before, is_uploading_files, is_sending_comment] - ); - - const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>( - ({ ctrlKey, key }) => { - if (!!ctrlKey && key === 'Enter') onSubmit(null); - }, - [onSubmit] - ); - - const images = useMemo( - () => comment.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), - [comment.files] - ); - - const locked_images = useMemo( - () => - comment.temp_ids - .filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.IMAGE) - .map(temp_id => statuses[temp_id]), - [statuses, comment.temp_ids] - ); - - const audios = useMemo( - () => comment.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), - [comment.files] - ); - - const locked_audios = useMemo( - () => - comment.temp_ids - .filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.AUDIO) - .map(temp_id => statuses[temp_id]), - [statuses, comment.temp_ids] - ); - - const onFileDrop = useCallback( - (file_id: IFile['id']) => { - nodeSetCommentData( - id, - assocPath(['files'], comment.files.filter(file => file.id != file_id), comment_data[id]) + const filtered_temps = temp_ids.filter( + temp_id => + statuses[temp_id] && + (!statuses[temp_id].uuid || !added_files.some(file => file.id === statuses[temp_id].uuid)) ); - }, - [comment_data, id, nodeSetCommentData] - ); - const onImageMove = useCallback( - ({ oldIndex, newIndex }: SortEnd) => { - nodeSetCommentData( - id, - assocPath( - ['files'], - [ - ...audios, - ...(moveArrItem(oldIndex, newIndex, images.filter(file => !!file)) as IFile[]), - ], - comment_data[id] - ) - ); - }, - [images, audios] - ); + if (added_files.length) { + nodeSetCommentData(id, { + ...comment_data[id], + temp_ids: filtered_temps, + files: [...comment_data[id].files, ...added_files], + }); + } + }, [statuses, files]); - const onAudioMove = useCallback( - ({ oldIndex, newIndex }: SortEnd) => { - nodeSetCommentData( - id, - assocPath( - ['files'], - [ - ...images, - ...(moveArrItem(oldIndex, newIndex, audios.filter(file => !!file)) as IFile[]), - ], - comment_data[id] - ) - ); - }, - [images, audios] - ); + const comment = comment_data[id]; - const onCancelEdit = useCallback(() => { - nodeCancelCommentEdit(id); - }, [nodeCancelCommentEdit, comment.id]); + const is_uploading_files = useMemo(() => comment.temp_ids.length > 0, [comment.temp_ids]); - const placeholder = getRandomPhrase('SIMPLE'); + const onSubmit = useCallback( + event => { + if (event) event.preventDefault(); + if (is_uploading_files || is_sending_comment) return; - return ( - <form onSubmit={onSubmit} className={styles.wrap}> - <div className={styles.input}> - <Textarea - value={comment.text} - handler={onInput} - onKeyDown={onKeyDown} - disabled={is_sending_comment} - placeholder={placeholder} - minRows={2} - /> - </div> + nodePostComment(id, is_before); + }, + [nodePostComment, id, is_before, is_uploading_files, is_sending_comment] + ); - {(!!images.length || !!audios.length) && ( - <div className={styles.attaches}> - {!!images.length && ( - <SortableImageGrid - onDrop={onFileDrop} - onSortEnd={onImageMove} - axis="xy" - items={images} - locked={locked_images} - pressDelay={50} - helperClass={styles.helper} - size={120} - /> - )} + const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>( + ({ ctrlKey, key }) => { + if (!!ctrlKey && key === 'Enter') onSubmit(null); + }, + [onSubmit] + ); - {(!!audios.length || !!locked_audios.length) && ( - <SortableAudioGrid - items={audios} - onDrop={onFileDrop} - onSortEnd={onAudioMove} - axis="y" - locked={locked_audios} - pressDelay={50} - helperClass={styles.helper} - /> - )} + const images = useMemo( + () => comment.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), + [comment.files] + ); + + const locked_images = useMemo( + () => + comment.temp_ids + .filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.IMAGE) + .map(temp_id => statuses[temp_id]), + [statuses, comment.temp_ids] + ); + + const audios = useMemo( + () => comment.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), + [comment.files] + ); + + const locked_audios = useMemo( + () => + comment.temp_ids + .filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.AUDIO) + .map(temp_id => statuses[temp_id]), + [statuses, comment.temp_ids] + ); + + const onFileDrop = useCallback( + (fileId: IFile['id']) => { + nodeSetCommentData( + id, + assocPath(['files'], comment.files.filter(file => file.id != fileId), comment_data[id]) + ); + }, + [comment_data, id, nodeSetCommentData] + ); + + const onTitleChange = useCallback( + (fileId: IFile['id'], title: IFile['metadata']['title']) => { + nodeSetCommentData( + id, + assocPath( + ['files'], + comment.files.map(file => + file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file + ), + comment_data[id] + ) + ); + }, + [comment_data, id, nodeSetCommentData] + ); + + const onImageMove = useCallback( + ({ oldIndex, newIndex }: SortEnd) => { + nodeSetCommentData( + id, + assocPath( + ['files'], + [ + ...audios, + ...(moveArrItem(oldIndex, newIndex, images.filter(file => !!file)) as IFile[]), + ], + comment_data[id] + ) + ); + }, + [images, audios] + ); + + const onAudioMove = useCallback( + ({ oldIndex, newIndex }: SortEnd) => { + nodeSetCommentData( + id, + assocPath( + ['files'], + [ + ...images, + ...(moveArrItem(oldIndex, newIndex, audios.filter(file => !!file)) as IFile[]), + ], + comment_data[id] + ) + ); + }, + [images, audios] + ); + + const onCancelEdit = useCallback(() => { + nodeCancelCommentEdit(id); + }, [nodeCancelCommentEdit, comment.id]); + + const placeholder = getRandomPhrase('SIMPLE'); + + return ( + <form onSubmit={onSubmit} className={styles.wrap}> + <div className={styles.input}> + <Textarea + value={comment.text} + handler={onInput} + onKeyDown={onKeyDown} + disabled={is_sending_comment} + placeholder={placeholder} + minRows={2} + /> </div> - )} - <Group horizontal className={styles.buttons}> - <ButtonGroup> - <Button iconLeft="photo" size="small" color="gray" iconOnly> - <input type="file" onInput={onInputChange} multiple accept="image/*" /> - </Button> + {(!!images.length || !!audios.length) && ( + <div className={styles.attaches}> + {!!images.length && ( + <SortableImageGrid + onDrop={onFileDrop} + onSortEnd={onImageMove} + axis="xy" + items={images} + locked={locked_images} + pressDelay={50} + helperClass={styles.helper} + size={120} + /> + )} - <Button iconRight="audio" size="small" color="gray" iconOnly> - <input type="file" onInput={onInputChange} multiple accept="audio/*" /> - </Button> - </ButtonGroup> - - <Filler /> - - {(is_sending_comment || is_uploading_files) && <LoaderCircle size={20} />} - - {id !== 0 && ( - <Button size="small" color="link" type="button" onClick={onCancelEdit}> - Отмена - </Button> + {(!!audios.length || !!locked_audios.length) && ( + <SortableAudioGrid + items={audios} + onDrop={onFileDrop} + onTitleChange={onTitleChange} + onSortEnd={onAudioMove} + axis="y" + locked={locked_audios} + pressDelay={50} + helperClass={styles.helper} + /> + )} + </div> )} - <Button - size="small" - color="gray" - iconRight={id === 0 ? 'enter' : 'check'} - disabled={is_sending_comment || is_uploading_files} - > - {id === 0 ? 'Сказать' : 'Сохранить'} - </Button> - </Group> - </form> - ); -}; + <Group horizontal className={styles.buttons}> + <ButtonGroup> + <Button iconLeft="photo" size="small" color="gray" iconOnly> + <input type="file" onInput={onInputChange} multiple accept="image/*" /> + </Button> + + <Button iconRight="audio" size="small" color="gray" iconOnly> + <input type="file" onInput={onInputChange} multiple accept="audio/*" /> + </Button> + </ButtonGroup> + + <Filler /> + + {(is_sending_comment || is_uploading_files) && <LoaderCircle size={20} />} + + {id !== 0 && ( + <Button size="small" color="link" type="button" onClick={onCancelEdit}> + Отмена + </Button> + )} + + <Button + size="small" + color="gray" + iconRight={id === 0 ? 'enter' : 'check'} + disabled={is_sending_comment || is_uploading_files} + > + {id === 0 ? 'Сказать' : 'Сохранить'} + </Button> + </Group> + </form> + ); + } +); const CommentForm = connect( mapStateToProps, diff --git a/src/redux/types.ts b/src/redux/types.ts index ae0a4ccc..8ef305fb 100644 --- a/src/redux/types.ts +++ b/src/redux/types.ts @@ -64,6 +64,7 @@ export interface IFile { node_id?: UUID; name: string; + orig_name: string; path: string; full_path: string; url: string; diff --git a/src/redux/uploads/constants.ts b/src/redux/uploads/constants.ts index 21a03c8a..1662ef99 100644 --- a/src/redux/uploads/constants.ts +++ b/src/redux/uploads/constants.ts @@ -19,13 +19,14 @@ export const EMPTY_FILE: IFile = { user_id: null, node_id: null, - name: 'mario-collage-800x450.jpg', - path: '/wp-content/uploads/2017/09/', - full_path: '/wp-content/uploads/2017/09/mario-collage-800x450.jpg', - url: 'https://cdn.arstechnica.net/wp-content/uploads/2017/09/mario-collage-800x450.jpg', - size: 2400000, - type: 'image', - mime: 'image/jpeg', + name: '', + orig_name: '', + path: '', + full_path: '', + url: '', + size: 0, + type: null, + mime: '', }; export const EMPTY_UPLOAD_STATUS: IUploadStatus = {