1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 12:56:41 +07:00

Merge branch 'develop' into 23-labs

This commit is contained in:
Fedor Katurov 2021-03-05 17:31:33 +07:00
commit 44bbc4cd4c
147 changed files with 3292 additions and 2627 deletions

View file

@ -1,11 +1,8 @@
import React, { FC, HTMLAttributes, memo } from 'react';
import { CommentWrapper } from '~/components/containers/CommentWrapper';
import { ICommentGroup } from '~/redux/types';
import { IComment, ICommentGroup } from '~/redux/types';
import { CommentContent } from '~/components/comment/CommentContent';
import styles from './styles.module.scss';
import { nodeEditComment, nodeLockComment } from '~/redux/node/actions';
import { INodeState } from '~/redux/node/reducer';
import { CommentForm } from '../CommentForm';
import { CommendDeleted } from '../../node/CommendDeleted';
import * as MODAL_ACTIONS from '~/redux/modal/actions';
@ -13,25 +10,21 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
is_empty?: boolean;
is_loading?: boolean;
comment_group: ICommentGroup;
comment_data: INodeState['comment_data'];
is_same?: boolean;
can_edit?: boolean;
onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
onDelete: (id: IComment['id'], isLocked: boolean) => void;
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
};
const Comment: FC<IProps> = memo(
({
comment_group,
comment_data,
is_empty,
is_same,
is_loading,
className,
can_edit,
onDelete,
onEdit,
modalShowPhotoswipe,
...props
}) => {
@ -50,17 +43,12 @@ const Comment: FC<IProps> = memo(
return <CommendDeleted id={comment.id} onDelete={onDelete} key={comment.id} />;
}
if (Object.prototype.hasOwnProperty.call(comment_data, comment.id)) {
return <CommentForm id={comment.id} key={comment.id} />;
}
return (
<CommentContent
comment={comment}
key={comment.id}
can_edit={!!can_edit}
onDelete={onDelete}
onEdit={onEdit}
modalShowPhotoswipe={modalShowPhotoswipe}
/>
);

View file

@ -1,104 +1,116 @@
import React, { FC, useMemo, memo, createElement, useCallback, Fragment } from 'react';
import React, { createElement, FC, Fragment, memo, useCallback, useMemo, useState } from 'react';
import { IComment, IFile } from '~/redux/types';
import { path } from 'ramda';
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
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 { assocPath } from 'ramda';
import { append } from 'ramda';
import reduce from 'ramda/es/reduce';
import { AudioPlayer } from '~/components/media/AudioPlayer';
import classnames from 'classnames';
import { PRESETS } from '~/constants/urls';
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
import { CommentMenu } from '../CommentMenu';
import * as MODAL_ACTIONS from '~/redux/modal/actions';
import { CommentForm } from '~/components/comment/CommentForm';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectNode } from '~/redux/node/selectors';
interface IProps {
comment: IComment;
can_edit: boolean;
onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
onDelete: (id: IComment['id'], isLocked: boolean) => void;
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
}
const CommentContent: FC<IProps> = memo(
({ comment, can_edit, onDelete, onEdit, modalShowPhotoswipe }) => {
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
() =>
reduce(
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
{},
comment.files
),
[comment]
);
const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, modalShowPhotoswipe }) => {
const [isEditing, setIsEditing] = useState(false);
const { current } = useShallowSelect(selectNode);
const onLockClick = useCallback(() => {
onDelete(comment.id, !comment.deleted_at);
}, [comment, onDelete]);
const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]);
const onEditClick = useCallback(() => {
onEdit(comment.id);
}, [comment, onEdit]);
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
() =>
reduce(
(group, file) =>
file.type ? assocPath([file.type], append(file, group[file.type]), group) : group,
{},
comment.files
),
[comment]
);
const menu = useMemo(
() => can_edit && <CommentMenu onDelete={onLockClick} onEdit={onEditClick} />,
[can_edit, comment, onEditClick, onLockClick]
);
const onLockClick = useCallback(() => {
onDelete(comment.id, !comment.deleted_at);
}, [comment, onDelete]);
return (
<div className={styles.wrap}>
{comment.text && (
<Group className={classnames(styles.block, styles.block_text)}>
{menu}
const menu = useMemo(
() => can_edit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />,
[can_edit, startEditing, onLockClick]
);
<Group className={styles.renderers}>
{formatCommentText(path(['user', 'username'], comment), comment.text).map(
(block, key) =>
COMMENT_BLOCK_RENDERERS[block.type] &&
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
)}
</Group>
const blocks = useMemo(
() =>
!!comment.text.trim()
? formatCommentText(path(['user', 'username'], comment), comment.text)
: [],
[comment]
);
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
if (isEditing) {
return <CommentForm nodeId={current.id} comment={comment} onCancelEdit={stopEditing} />;
}
return (
<div className={styles.wrap}>
{comment.text && (
<Group className={classnames(styles.block, styles.block_text)}>
{menu}
<Group className={styles.renderers}>
{blocks.map(
(block, key) =>
COMMENT_BLOCK_RENDERERS[block.type] &&
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
)}
</Group>
)}
{groupped.image && groupped.image.length > 0 && (
<div className={classnames(styles.block, styles.block_image)}>
{menu}
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</Group>
)}
<div className={styles.images}>
{groupped.image.map((file, index) => (
<div key={file.id} onClick={() => modalShowPhotoswipe(groupped.image, index)}>
<img src={getURL(file, PRESETS['600'])} alt={file.name} />
</div>
))}
</div>
{groupped.image && groupped.image.length > 0 && (
<div className={classnames(styles.block, styles.block_image)}>
{menu}
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div>
)}
{groupped.audio && groupped.audio.length > 0 && (
<Fragment>
{groupped.audio.map(file => (
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
{menu}
<AudioPlayer file={file} />
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
<div className={styles.images}>
{groupped.image.map((file, index) => (
<div key={file.id} onClick={() => modalShowPhotoswipe(groupped.image, index)}>
<img src={getURL(file, PRESETS['600'])} alt={file.name} />
</div>
))}
</Fragment>
)}
</div>
);
}
);
</div>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div>
)}
{groupped.audio && groupped.audio.length > 0 && (
<Fragment>
{groupped.audio.map(file => (
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
{menu}
<AudioPlayer file={file} />
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div>
))}
</Fragment>
)}
</div>
);
});
export { CommentContent };

View file

@ -6,6 +6,7 @@ import { selectPlayer } from '~/redux/player/selectors';
import { connect } from 'react-redux';
import * as PLAYER_ACTIONS from '~/redux/player/actions';
import { Icon } from '~/components/input/Icon';
import { path } from 'ramda';
const mapStateToProps = state => ({
youtubes: selectPlayer(state).youtubes,
@ -21,30 +22,32 @@ type Props = ReturnType<typeof mapStateToProps> &
const CommentEmbedBlockUnconnected: FC<Props> = memo(
({ block, youtubes, playerGetYoutubeInfo }) => {
const link = useMemo(
() =>
block.content.match(
/https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?([\w\-\=]+)/
),
[block.content]
);
const id = useMemo(() => {
const match = block.content.match(
/https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch)?(?:\?v=)?([\w\-\=]+)/
);
return (match && match[1]) || '';
}, [block.content]);
const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]);
useEffect(() => {
if (!link[5] || youtubes[link[5]]) return;
playerGetYoutubeInfo(link[5]);
}, [link, playerGetYoutubeInfo]);
if (!id) return;
playerGetYoutubeInfo(id);
}, [id, playerGetYoutubeInfo]);
const title = useMemo(
() =>
(youtubes[link[5]] && youtubes[link[5]].metadata && youtubes[link[5]].metadata.title) || '',
[link, youtubes]
);
const title = useMemo<string>(() => {
if (!id) {
return block.content;
}
return path([id, 'metadata', 'title'], youtubes) || block.content;
}, [id, youtubes, block.content]);
return (
<div className={styles.embed}>
<a href={link[0]} target="_blank" />
<a href={id[0]} target="_blank" />
<div className={styles.preview}>
<div style={{ backgroundImage: `url("${preview}")` }}>
@ -53,7 +56,7 @@ const CommentEmbedBlockUnconnected: FC<Props> = memo(
<Icon icon="play" size={32} />
</div>
<div className={styles.title}>{title || link[0]}</div>
<div className={styles.title}>{title}</div>
</div>
</div>
</div>

View file

@ -1,234 +1,103 @@
import React, { FC, KeyboardEventHandler, memo, useCallback, useEffect, useMemo } from 'react';
import { Textarea } from '~/components/input/Textarea';
import styles from './styles.module.scss';
import { Filler } from '~/components/containers/Filler';
import React, { FC, useCallback, useState } from 'react';
import { useCommentFormFormik } from '~/utils/hooks/useCommentFormFormik';
import { FormikProvider } from 'formik';
import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea';
import { Button } from '~/components/input/Button';
import assocPath from 'ramda/es/assocPath';
import { IComment, IFileWithUUID, InputHandler } from '~/redux/types';
import { connect } from 'react-redux';
import * as NODE_ACTIONS from '~/redux/node/actions';
import { selectNode } from '~/redux/node/selectors';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { Group } from '~/components/containers/Group';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants';
import uuid from 'uuid4';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { getFileType } from '~/utils/uploader';
import { useRandomPhrase } from '~/constants/phrases';
import { ERROR_LITERAL } from '~/constants/errors';
import { FileUploaderProvider, useFileUploader } from '~/utils/hooks/fileUploader';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons';
import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons';
import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches';
import { CommentFormAttachButtons } from '~/components/comment/CommentFormButtons';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { IComment, INode } from '~/redux/types';
import { EMPTY_COMMENT } from '~/redux/node/constants';
import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone';
import styles from './styles.module.scss';
import { ERROR_LITERAL } from '~/constants/errors';
import { Group } from '~/components/containers/Group';
const mapStateToProps = (state: IState) => ({
node: selectNode(state),
uploads: selectUploads(state),
});
interface IProps {
comment?: IComment;
nodeId: INode['id'];
onCancelEdit?: () => void;
}
const mapDispatchToProps = {
nodePostComment: NODE_ACTIONS.nodePostComment,
nodeCancelCommentEdit: NODE_ACTIONS.nodeCancelCommentEdit,
nodeSetCommentData: NODE_ACTIONS.nodeSetCommentData,
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => {
const [textarea, setTextarea] = useState<HTMLTextAreaElement>();
const uploader = useFileUploader(
UPLOAD_SUBJECTS.COMMENT,
UPLOAD_TARGETS.COMMENTS,
comment?.files
);
const formik = useCommentFormFormik(comment || EMPTY_COMMENT, nodeId, uploader, onCancelEdit);
const isLoading = formik.isSubmitting || uploader.isUploading;
const isEditing = !!comment?.id;
const clearError = useCallback(() => {
if (formik.status) {
formik.setStatus('');
}
if (formik.errors.text) {
formik.setErrors({
...formik.errors,
text: '',
});
}
}, [formik]);
const error = formik.status || formik.errors.text;
return (
<CommentFormDropzone onUpload={uploader.uploadFiles}>
<form onSubmit={formik.handleSubmit} className={styles.wrap}>
<FormikProvider value={formik}>
<FileUploaderProvider value={uploader}>
<div className={styles.input}>
<LocalCommentFormTextarea setRef={setTextarea} />
{!!error && (
<div className={styles.error} onClick={clearError}>
{ERROR_LITERAL[error] || error}
</div>
)}
</div>
<CommentFormAttaches />
<Group horizontal className={styles.buttons}>
<CommentFormAttachButtons onUpload={uploader.uploadFiles} />
{!!textarea && (
<CommentFormFormatButtons
element={textarea}
handler={formik.handleChange('text')}
/>
)}
{isLoading && <LoaderCircle size={20} />}
{isEditing && (
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
Отмена
</Button>
)}
<Button
type="submit"
size="small"
color="gray"
iconRight={!isEditing ? 'enter' : 'check'}
disabled={isLoading}
>
{!isEditing ? 'Сказать' : 'Сохранить'}
</Button>
</Group>
</FileUploaderProvider>
</FormikProvider>
</form>
</CommentFormDropzone>
);
};
type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
id: number;
is_before?: boolean;
};
const CommentFormUnconnected: FC<IProps> = memo(
({
node: { comment_data, is_sending_comment },
uploads: { statuses, files },
id,
is_before = false,
nodePostComment,
nodeSetCommentData,
uploadUploadFiles,
nodeCancelCommentEdit,
}) => {
const comment = useMemo(() => comment_data[id], [comment_data, id]);
const onUpload = useCallback(
(files: File[]) => {
console.log(files);
const items: IFileWithUUID[] = 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);
nodeSetCommentData(id, assocPath(['temp_ids'], [...comment.temp_ids, ...temps], comment));
uploadUploadFiles(items);
},
[uploadUploadFiles, comment, id, nodeSetCommentData]
);
const onInput = useCallback<InputHandler>(
text => {
nodeSetCommentData(id, assocPath(['text'], text, comment));
},
[nodeSetCommentData, comment, id]
);
useEffect(() => {
const temp_ids = (comment && comment.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.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))
);
if (added_files.length) {
nodeSetCommentData(id, {
...comment,
temp_ids: filtered_temps,
files: [...comment.files, ...added_files],
});
}
}, [statuses, files]);
const isUploadingNow = useMemo(() => comment.temp_ids.length > 0, [comment.temp_ids]);
const onSubmit = useCallback(
event => {
if (event) event.preventDefault();
if (isUploadingNow || is_sending_comment) return;
nodePostComment(id, is_before);
},
[nodePostComment, id, is_before, isUploadingNow, 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 onCancelEdit = useCallback(() => {
nodeCancelCommentEdit(id);
}, [nodeCancelCommentEdit, comment.id]);
const placeholder = useRandomPhrase('SIMPLE');
const clearError = useCallback(() => nodeSetCommentData(id, { error: '' }), [
id,
nodeSetCommentData,
]);
useEffect(() => {
if (comment.error) clearError();
}, [comment.files, comment.text]);
const setData = useCallback(
(data: Partial<IComment>) => {
nodeSetCommentData(id, data);
},
[nodeSetCommentData, id]
);
return (
<CommentFormDropzone onUpload={onUpload}>
<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}
/>
{comment.error && (
<div className={styles.error} onClick={clearError}>
{ERROR_LITERAL[comment.error] || comment.error}
</div>
)}
</div>
<CommentFormAttaches
images={images}
audios={audios}
locked_audios={locked_audios}
locked_images={locked_images}
comment={comment}
setComment={setData}
onUpload={onUpload}
/>
<Group horizontal className={styles.buttons}>
<CommentFormAttachButtons onUpload={onUpload} />
<Filler />
{(is_sending_comment || isUploadingNow) && <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 || isUploadingNow}
>
{id === 0 ? 'Сказать' : 'Сохранить'}
</Button>
</Group>
</form>
</CommentFormDropzone>
);
}
);
const CommentForm = connect(mapStateToProps, mapDispatchToProps)(CommentFormUnconnected);
export { CommentForm, CommentFormUnconnected };
export { CommentForm };

View file

@ -19,12 +19,15 @@
.buttons {
@include outer_shadow();
position: relative;
z-index: 1;
display: flex;
flex-direction: row;
background: transparentize(black, 0.8);
padding: $gap / 2;
border-radius: 0 0 $radius $radius;
flex-wrap: wrap;
}
.uploads {

View file

@ -1,138 +1,116 @@
import React, { FC, useCallback } from 'react';
import styles from '~/components/comment/CommentForm/styles.module.scss';
import React, { FC, useCallback, useMemo } from 'react';
import styles from './styles.module.scss';
import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
import { IComment, IFile } from '~/redux/types';
import { IUploadStatus } from '~/redux/uploads/reducer';
import { IFile } from '~/redux/types';
import { SortEnd } from 'react-sortable-hoc';
import assocPath from 'ramda/es/assocPath';
import { moveArrItem } from '~/utils/fn';
import { useDropZone } from '~/utils/hooks';
import { COMMENT_FILE_TYPES } from '~/redux/uploads/constants';
import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants';
import { useFileUploaderContext } from '~/utils/hooks/fileUploader';
interface IProps {
images: IFile[];
audios: IFile[];
locked_images: IUploadStatus[];
locked_audios: IUploadStatus[];
comment: IComment;
setComment: (data: IComment) => void;
onUpload: (files: File[]) => void;
}
const CommentFormAttaches: FC = () => {
const uploader = useFileUploaderContext();
const { files, pending, setFiles, uploadFiles } = uploader!;
const CommentFormAttaches: FC<IProps> = ({
images,
audios,
locked_images,
locked_audios,
comment,
setComment,
onUpload,
}) => {
const onDrop = useDropZone(onUpload, COMMENT_FILE_TYPES);
const images = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [
files,
]);
const hasImageAttaches = images.length > 0 || locked_images.length > 0;
const hasAudioAttaches = audios.length > 0 || locked_audios.length > 0;
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,
]);
const onDrop = useDropZone(uploadFiles, COMMENT_FILE_TYPES);
const hasImageAttaches = images.length > 0 || pendingImages.length > 0;
const hasAudioAttaches = audios.length > 0 || pendingAudios.length > 0;
const hasAttaches = hasImageAttaches || hasAudioAttaches;
const onImageMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setComment(
assocPath(
['files'],
[
...audios,
...(moveArrItem(
oldIndex,
newIndex,
images.filter(file => !!file)
) as IFile[]),
],
comment
)
);
setFiles([
...audios,
...(moveArrItem(
oldIndex,
newIndex,
images.filter(file => !!file)
) as IFile[]),
]);
},
[images, audios, comment, setComment]
);
const onFileDelete = useCallback(
(fileId: IFile['id']) => {
setComment(
assocPath(
['files'],
comment.files.filter(file => file.id != fileId),
comment
)
);
},
[setComment, comment]
);
const onTitleChange = useCallback(
(fileId: IFile['id'], title: IFile['metadata']['title']) => {
setComment(
assocPath(
['files'],
comment.files.map(file =>
file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file
),
comment
)
);
},
[comment, setComment]
[images, audios, setFiles]
);
const onAudioMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setComment(
assocPath(
['files'],
[
...images,
...(moveArrItem(
oldIndex,
newIndex,
audios.filter(file => !!file)
) as IFile[]),
],
comment
setFiles([
...images,
...(moveArrItem(
oldIndex,
newIndex,
audios.filter(file => !!file)
) as IFile[]),
]);
},
[images, audios, setFiles]
);
const onFileDelete = useCallback(
(fileId: IFile['id']) => {
setFiles(files.filter(file => file.id !== fileId));
},
[setFiles, files]
);
const onAudioTitleChange = useCallback(
(fileId: IFile['id'], title: string) => {
setFiles(
files.map(file =>
file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file
)
);
},
[images, audios, comment, setComment]
[files, setFiles]
);
return (
hasAttaches && (
<div className={styles.attaches} onDropCapture={onDrop}>
{hasImageAttaches && (
<SortableImageGrid
onDelete={onFileDelete}
onSortEnd={onImageMove}
axis="xy"
items={images}
locked={locked_images}
pressDelay={50}
helperClass={styles.helper}
size={120}
/>
)}
if (!hasAttaches) return null;
{hasAudioAttaches && (
<SortableAudioGrid
items={audios}
onDelete={onFileDelete}
onTitleChange={onTitleChange}
onSortEnd={onAudioMove}
axis="y"
locked={locked_audios}
pressDelay={50}
helperClass={styles.helper}
/>
)}
</div>
)
return (
<div className={styles.attaches} onDropCapture={onDrop}>
{hasImageAttaches && (
<SortableImageGrid
onDelete={onFileDelete}
onSortEnd={onImageMove}
axis="xy"
items={images}
locked={pendingImages}
pressDelay={50}
helperClass={styles.helper}
size={120}
/>
)}
{hasAudioAttaches && (
<SortableAudioGrid
items={audios}
onDelete={onFileDelete}
onTitleChange={onAudioTitleChange}
onSortEnd={onAudioMove}
axis="y"
locked={pendingAudios}
pressDelay={50}
helperClass={styles.helper}
/>
)}
</div>
);
};

View file

@ -0,0 +1,9 @@
@import "src/styles/variables";
.attaches {
@include outer_shadow();
}
.helper {
z-index: 10000 !important;
}

View file

@ -0,0 +1,115 @@
import React, { FC, useCallback, useEffect } from 'react';
import { ButtonGroup } from '~/components/input/ButtonGroup';
import { Button } from '~/components/input/Button';
import { useFormatWrapper, wrapTextInsideInput } from '~/utils/hooks/useFormatWrapper';
import styles from './styles.module.scss';
interface IProps {
element: HTMLTextAreaElement;
handler: (val: string) => void;
}
const CommentFormFormatButtons: FC<IProps> = ({ element, handler }) => {
const wrap = useCallback(
(prefix = '', suffix = '') => useFormatWrapper(element, handler, prefix, suffix),
[element, handler]
);
const wrapBold = useCallback(
event => {
event.preventDefault();
wrapTextInsideInput(element, '**', '**', handler);
},
[wrap, handler]
);
const wrapItalic = useCallback(
event => {
event.preventDefault();
wrapTextInsideInput(element, '*', '*', handler);
},
[wrap, handler]
);
const onKeyPress = useCallback(
(event: KeyboardEvent) => {
if (!event.ctrlKey) return;
if (event.code === 'KeyB') {
wrapBold(event);
}
if (event.code === 'KeyI') {
wrapItalic(event);
}
},
[wrapBold, wrapItalic]
);
useEffect(() => {
if (!element) {
return;
}
element.addEventListener('keypress', onKeyPress);
return () => element.removeEventListener('keypress', onKeyPress);
}, [element, onKeyPress]);
return (
<ButtonGroup className={styles.wrap}>
<Button
onClick={wrapBold}
iconLeft="bold"
size="small"
color="gray"
iconOnly
type="button"
label="Жирный Ctrl+B"
/>
<Button
onClick={wrap('*', '*')}
iconLeft="italic"
size="small"
color="gray"
iconOnly
type="button"
label="Наклонный Ctrl+I"
/>
<Button
onClick={wrap('## ', '')}
iconLeft="title"
size="small"
color="gray"
iconOnly
type="button"
label="Заголовок"
/>
<Button
onClick={wrap('[ссылка](', ')')}
iconLeft="link"
size="small"
color="gray"
iconOnly
type="button"
label="Ссылка"
/>
<Button
onClick={wrap('// ')}
size="small"
color="gray"
iconOnly
type="button"
label="Коммент"
>
{`/ /`}
</Button>
</ButtonGroup>
);
};
export { CommentFormFormatButtons };

View file

@ -0,0 +1,12 @@
@import '~/styles/variables.scss';
.wrap {
display: flex;
flex-wrap: wrap;
height: 32px;
flex: 1;
@media(max-width: 480px) {
display: none;
}
}

View file

@ -1,15 +1,20 @@
import React, { FC } from 'react';
import React, { FC, useMemo } from 'react';
import { ICommentBlockProps } from '~/constants/comment';
import styles from './styles.module.scss';
import classNames from 'classnames';
import markdown from '~/styles/common/markdown.module.scss';
import { formatText } from '~/utils/dom';
interface IProps extends ICommentBlockProps {}
const CommentTextBlock: FC<IProps> = ({ block }) => {
const content = useMemo(() => formatText(block.content), [block.content]);
return (
<div
className={styles.text}
className={classNames(styles.text, markdown.wrapper)}
dangerouslySetInnerHTML={{
__html: `<p>${block.content}</p>`,
__html: content,
}}
/>
);

View file

@ -28,15 +28,4 @@
:global(.green) {
color: $wisegreen;
}
&:last-child {
p {
&::after {
content: '';
display: inline-flex;
height: 1em;
width: 150px;
}
}
}
}

View file

@ -0,0 +1,36 @@
import React, { FC, KeyboardEventHandler, useCallback } from 'react';
import { Textarea } from '~/components/input/Textarea';
import { useCommentFormContext } from '~/utils/hooks/useCommentFormFormik';
import { useRandomPhrase } from '~/constants/phrases';
interface IProps {
isLoading?: boolean;
setRef?: (r: HTMLTextAreaElement) => void;
}
const LocalCommentFormTextarea: FC<IProps> = ({ setRef }) => {
const { values, handleChange, handleSubmit, isSubmitting } = useCommentFormContext();
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
({ ctrlKey, key }) => {
if (ctrlKey && key === 'Enter') handleSubmit(undefined);
},
[handleSubmit]
);
const placeholder = useRandomPhrase('SIMPLE');
return (
<Textarea
value={values.text}
handler={handleChange('text')}
onKeyDown={onKeyDown}
disabled={isSubmitting}
placeholder={placeholder}
minRows={2}
setRef={setRef}
/>
);
};
export { LocalCommentFormTextarea };

View file

@ -1,16 +1,16 @@
import React, { FC, useState, useCallback, useEffect, useRef } from "react";
import { IUser } from "~/redux/auth/types";
import React, { FC, useState, useCallback, useEffect, useRef } from 'react';
import { IUser } from '~/redux/auth/types';
import styles from './styles.module.scss';
import { getURL } from "~/utils/dom";
import { PRESETS } from "~/constants/urls";
import classNames from "classnames";
import { getURL } from '~/utils/dom';
import { PRESETS } from '~/constants/urls';
import classNames from 'classnames';
interface IProps {
cover: IUser["cover"];
cover: IUser['cover'];
}
const CoverBackdrop: FC<IProps> = ({ cover }) => {
const ref = useRef<HTMLImageElement>();
const ref = useRef<HTMLImageElement>(null);
const [is_loaded, setIsLoaded] = useState(false);
@ -21,7 +21,7 @@ const CoverBackdrop: FC<IProps> = ({ cover }) => {
useEffect(() => {
if (!cover || !cover.url || !ref || !ref.current) return;
ref.current.src = "";
ref.current.src = '';
setIsLoaded(false);
ref.current.src = getURL(cover, PRESETS.cover);
}, [cover]);

View file

@ -16,7 +16,7 @@ const FullWidth: FC<IProps> = ({ children, onRefresh }) => {
const { width } = sample.current.getBoundingClientRect();
const { clientWidth } = document.documentElement;
onRefresh(clientWidth);
if (onRefresh) onRefresh(clientWidth);
return {
width: clientWidth,

View file

@ -11,7 +11,7 @@ interface IProps extends DetailsHTMLAttributes<HTMLDivElement> {}
const Sticky: FC<IProps> = ({ children }) => {
const ref = useRef(null);
let sb = null;
let sb;
useEffect(() => {
if (!ref.current) return;

View file

@ -1,5 +1,4 @@
import React, { FC, useCallback, useMemo } from 'react';
import { INode } from '~/redux/types';
import { connect } from 'react-redux';
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { ImageGrid } from '../ImageGrid';
@ -8,19 +7,14 @@ import { selectUploads } from '~/redux/uploads/selectors';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import styles from './styles.module.scss';
import { NodeEditorProps } from '~/redux/node/types';
const mapStateToProps = selectUploads;
const mapDispatchToProps = {
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
};
type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
data: INode;
setData: (val: INode) => void;
temp: string[];
setTemp: (val: string[]) => void;
};
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & NodeEditorProps;
const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
const images = useMemo(
@ -69,9 +63,6 @@ const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) =
);
};
const AudioEditor = connect(
mapStateToProps,
mapDispatchToProps
)(AudioEditorUnconnected);
const AudioEditor = connect(mapStateToProps, mapDispatchToProps)(AudioEditorUnconnected);
export { AudioEditor };

View file

@ -35,7 +35,7 @@ const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => {
);
const onTitleChange = useCallback(
(changeId: IFile['id'], title: IFile['metadata']['title']) => {
(changeId: IFile['id'], title: string) => {
setFiles(
files.map(file =>
file && file.id === changeId ? { ...file, metadata: { ...file.metadata, title } } : file

View file

@ -2,6 +2,7 @@ import React, { FC, createElement } from 'react';
import styles from './styles.module.scss';
import { INode } from '~/redux/types';
import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants';
import { has } from 'ramda';
interface IProps {
data: INode;
@ -10,13 +11,19 @@ interface IProps {
setTemp: (val: string[]) => void;
}
const EditorPanel: FC<IProps> = ({ data, setData, temp, setTemp }) => (
<div className={styles.panel}>
{NODE_PANEL_COMPONENTS[data.type] &&
NODE_PANEL_COMPONENTS[data.type].map((el, key) =>
createElement(el, { key, data, setData, temp, setTemp })
)}
</div>
);
const EditorPanel: FC<IProps> = ({ data, setData, temp, setTemp }) => {
if (!data.type || !has(data.type, NODE_PANEL_COMPONENTS)) {
return null;
}
return (
<div className={styles.panel}>
{NODE_PANEL_COMPONENTS[data.type] &&
NODE_PANEL_COMPONENTS[data.type].map((el, key) =>
createElement(el, { key, data, setData, temp, setTemp })
)}
</div>
);
};
export { EditorPanel };

View file

@ -59,7 +59,10 @@ const EditorUploadButtonUnconnected: FC<IProps> = ({
})
);
const temps = items.map(file => file.temp_id).slice(0, limit);
const temps = items
.filter(file => file?.temp_id)
.map(file => file.temp_id!)
.slice(0, limit);
setTemp([...temp, ...temps]);
uploadUploadFiles(items);

View file

@ -33,16 +33,16 @@ const EditorUploadCoverButtonUnconnected: FC<IProps> = ({
statuses,
uploadUploadFiles,
}) => {
const [cover_temp, setCoverTemp] = useState<string>(null);
const [coverTemp, setCoverTemp] = useState<string>('');
useEffect(() => {
Object.entries(statuses).forEach(([id, status]) => {
if (cover_temp === id && !!status.uuid && files[status.uuid]) {
if (coverTemp === id && !!status.uuid && files[status.uuid]) {
setData({ ...data, cover: files[status.uuid] });
setCoverTemp(null);
setCoverTemp('');
}
});
}, [statuses, files, cover_temp, setData, data]);
}, [statuses, files, coverTemp, setData, data]);
const onUpload = useCallback(
(uploads: File[]) => {
@ -56,7 +56,7 @@ const EditorUploadCoverButtonUnconnected: FC<IProps> = ({
})
);
setCoverTemp(path([0, 'temp_id'], items));
setCoverTemp(path([0, 'temp_id'], items) || '');
uploadUploadFiles(items);
},
[uploadUploadFiles, setCoverTemp]
@ -73,11 +73,11 @@ const EditorUploadCoverButtonUnconnected: FC<IProps> = ({
[onUpload]
);
const onDropCover = useCallback(() => {
setData({ ...data, cover: null });
setData({ ...data, cover: undefined });
}, [setData, data]);
const background = data.cover ? getURL(data.cover, PRESETS['300']) : null;
const status = cover_temp && path([cover_temp], statuses);
const status = coverTemp && path([coverTemp], statuses);
const preview = status && path(['preview'], status);
return (

View file

@ -5,19 +5,14 @@ import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { ImageGrid } from '~/components/editors/ImageGrid';
import styles from './styles.module.scss';
import { NodeEditorProps } from '~/redux/node/types';
const mapStateToProps = selectUploads;
const mapDispatchToProps = {
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
};
type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
data: INode;
setData: (val: INode) => void;
temp: string[];
setTemp: (val: string[]) => void;
};
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & NodeEditorProps;
const ImageEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
const pending_files = useMemo(() => temp.filter(id => !!statuses[id]).map(id => statuses[id]), [
@ -34,9 +29,6 @@ const ImageEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) =
);
};
const ImageEditor = connect(
mapStateToProps,
mapDispatchToProps
)(ImageEditorUnconnected);
const ImageEditor = connect(mapStateToProps, mapDispatchToProps)(ImageEditorUnconnected);
export { ImageEditor };

View file

@ -2,5 +2,5 @@
.helper {
opacity: 0.5;
z-index: 10 !important;
z-index: 10000 !important;
}

View file

@ -17,7 +17,7 @@ const SortableAudioGrid = SortableContainer(
items: IFile[];
locked: IUploadStatus[];
onDelete: (file_id: IFile['id']) => void;
onTitleChange: (file_id: IFile['id'], title: IFile['metadata']['title']) => void;
onTitleChange: (file_id: IFile['id'], title: string) => void;
}) => {
return (
<div className={styles.grid}>

View file

@ -3,11 +3,9 @@ import { INode } from '~/redux/types';
import styles from './styles.module.scss';
import { Textarea } from '~/components/input/Textarea';
import { path } from 'ramda';
import { NodeEditorProps } from '~/redux/node/types';
interface IProps {
data: INode;
setData: (val: INode) => void;
}
type IProps = NodeEditorProps & {};
const TextEditor: FC<IProps> = ({ data, setData }) => {
const setText = useCallback(

View file

@ -5,11 +5,9 @@ import { path } from 'ramda';
import { InputText } from '~/components/input/InputText';
import classnames from 'classnames';
import { getYoutubeThumb } from '~/utils/dom';
import { NodeEditorProps } from '~/redux/node/types';
interface IProps {
data: INode;
setData: (val: INode) => void;
}
type IProps = NodeEditorProps & {};
const VideoEditor: FC<IProps> = ({ data, setData }) => {
const setUrl = useCallback(
@ -19,9 +17,10 @@ const VideoEditor: FC<IProps> = ({ data, setData }) => {
const url = (path(['blocks', 0, 'url'], data) as string) || '';
const preview = useMemo(() => getYoutubeThumb(url), [url]);
const backgroundImage = (preview && `url("${preview}")`) || '';
return (
<div className={styles.preview} style={{ backgroundImage: preview && `url("${preview}")` }}>
<div className={styles.preview} style={{ backgroundImage }}>
<div className={styles.input_wrap}>
<div className={classnames(styles.input, { active: !!preview })}>
<InputText value={url} handler={setUrl} placeholder="Адрес видео" />

View file

@ -1,13 +1,13 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { FC, useCallback, useMemo, useRef, useState } from 'react';
import { INode } from '~/redux/types';
import { formatCellText, getURL } from '~/utils/dom';
import classNames from 'classnames';
import styles from './styles.module.scss';
import markdown from '~/styles/common/markdown.module.scss';
import { Icon } from '~/components/input/Icon';
import { flowSetCellView } from '~/redux/flow/actions';
import { PRESETS } from '~/constants/urls';
import { debounce } from 'throttle-debounce';
import { NODE_TYPES } from '~/redux/node/constants';
import { Group } from '~/components/containers/Group';
import { Link } from 'react-router-dom';
@ -33,43 +33,43 @@ const Cell: FC<IProps> = ({
}) => {
const ref = useRef(null);
const [is_loaded, setIsLoaded] = useState(false);
const [is_visible, setIsVisible] = useState(false);
const [is_visible, setIsVisible] = useState(true);
const checkIfVisible = useCallback(() => {
if (!ref.current) return;
// const checkIfVisible = useCallback(() => {
// if (!ref.current) return;
//
// const { top, height } = ref.current.getBoundingClientRect();
//
// // const visibility = top + height > -window.innerHeight && top < window.innerHeight * 2;
// const visibility = top + height > -600 && top < window.innerHeight + 600;
// if (visibility !== is_visible) setIsVisible(visibility);
// }, [ref, is_visible, setIsVisible]);
//
// const checkIfVisibleDebounced = useCallback(debounce(Math.random() * 100 + 100, checkIfVisible), [
// checkIfVisible,
// ]);
const { top, height } = ref.current.getBoundingClientRect();
// useEffect(() => {
// checkIfVisibleDebounced();
// }, []);
// const visibility = top + height > -window.innerHeight && top < window.innerHeight * 2;
const visibility = top + height > -600 && top < window.innerHeight + 600;
if (visibility !== is_visible) setIsVisible(visibility);
}, [ref, is_visible, setIsVisible]);
// useEffect(() => {
// recalc visibility of other elements
// window.dispatchEvent(new CustomEvent('scroll'));
// }, [flow]);
const checkIfVisibleDebounced = useCallback(debounce(Math.random() * 100 + 100, checkIfVisible), [
checkIfVisible,
]);
useEffect(() => {
checkIfVisibleDebounced();
}, []);
useEffect(() => {
// recalc visibility of other elements
window.dispatchEvent(new CustomEvent('scroll'));
}, [flow]);
useEffect(() => {
window.addEventListener('scroll', checkIfVisibleDebounced);
return () => window.removeEventListener('scroll', checkIfVisibleDebounced);
}, [checkIfVisibleDebounced]);
// useEffect(() => {
// window.addEventListener('scroll', checkIfVisibleDebounced);
//
// return () => window.removeEventListener('scroll', checkIfVisibleDebounced);
// }, [checkIfVisibleDebounced]);
const onImageLoad = useCallback(() => {
setIsLoaded(true);
}, [setIsLoaded]);
// Replaced it with <Link>, maybe, you can remove it completely with NodeSelect action
const onClick = useCallback(() => onSelect(id, type), [onSelect, id, type]);
// const onClick = useCallback(() => onSelect(id, type), [onSelect, id, type]);
const has_description = description && description.length > 32;
const text =
@ -119,6 +119,8 @@ const Cell: FC<IProps> = ({
}
}, [title]);
const cellText = useMemo(() => formatCellText(text || ''), [text]);
return (
<div className={classNames(styles.cell, styles[(flow && flow.display) || 'single'])} ref={ref}>
{is_visible && (
@ -150,7 +152,10 @@ const Cell: FC<IProps> = ({
<div className={styles.text}>
{title && <div className={styles.text_title}>{title}</div>}
<Group dangerouslySetInnerHTML={{ __html: formatCellText(text) }} />
<div
dangerouslySetInnerHTML={{ __html: cellText }}
className={markdown.wrapper}
/>
</div>
)}
@ -158,7 +163,10 @@ const Cell: FC<IProps> = ({
<div className={styles.text_only}>
{title && <div className={styles.text_title}>{title}</div>}
<Group dangerouslySetInnerHTML={{ __html: formatCellText(text) }} />
<div
dangerouslySetInnerHTML={{ __html: cellText }}
className={markdown.wrapper}
/>
</div>
)}
</div>

View file

@ -13,16 +13,22 @@ type IProps = Partial<IFlowState> & {
onChangeCellView: typeof flowSetCellView;
};
export const FlowGrid: FC<IProps> = ({ user, nodes, onSelect, onChangeCellView }) => (
<Fragment>
{nodes.map(node => (
<Cell
key={node.id}
node={node}
onSelect={onSelect}
can_edit={canEditNode(node, user)}
onChangeCellView={onChangeCellView}
/>
))}
</Fragment>
);
export const FlowGrid: FC<IProps> = ({ user, nodes, onSelect, onChangeCellView }) => {
if (!nodes) {
return null;
}
return (
<Fragment>
{nodes.map(node => (
<Cell
key={node.id}
node={node}
onSelect={onSelect}
can_edit={canEditNode(node, user)}
onChangeCellView={onChangeCellView}
/>
))}
</Fragment>
);
};

View file

@ -7,7 +7,7 @@ import { getURL } from '~/utils/dom';
import { withRouter, RouteComponentProps, useHistory } from 'react-router';
import { URLS, PRESETS } from '~/constants/urls';
import { Icon } from '~/components/input/Icon';
import { INode } from "~/redux/types";
import { INode } from '~/redux/types';
type IProps = RouteComponentProps & {
heroes: IFlowState['heroes'];
@ -18,46 +18,54 @@ const FlowHeroUnconnected: FC<IProps> = ({ heroes }) => {
const [limit, setLimit] = useState(6);
const [current, setCurrent] = useState(0);
const [loaded, setLoaded] = useState<Partial<INode>[]>([]);
const timer = useRef(null)
const timer = useRef<any>(null);
const history = useHistory();
const onLoad = useCallback((i: number) => {
setLoaded([...loaded, heroes[i]])
}, [heroes, loaded, setLoaded])
const onLoad = useCallback(
(i: number) => {
setLoaded([...loaded, heroes[i]]);
},
[heroes, loaded, setLoaded]
);
const items = Math.min(heroes.length, limit)
const items = Math.min(heroes.length, limit);
const title = useMemo(() => {
return loaded[current]?.title || '';
}, [loaded, current, heroes]);
const onNext = useCallback(() => {
if (heroes.length > limit) setLimit(limit + 1)
setCurrent(current < items - 1 ? current + 1 : 0)
}, [current, items, limit, heroes.length])
const onPrev = useCallback(() => setCurrent(current > 0 ? current - 1 : items - 1), [current, items])
if (heroes.length > limit) setLimit(limit + 1);
setCurrent(current < items - 1 ? current + 1 : 0);
}, [current, items, limit, heroes.length]);
const onPrev = useCallback(() => setCurrent(current > 0 ? current - 1 : items - 1), [
current,
items,
]);
const goToNode = useCallback(() => {
history.push(URLS.NODE_URL(loaded[current].id))
history.push(URLS.NODE_URL(loaded[current].id));
}, [current, loaded]);
useEffect(() => {
timer.current = setTimeout(onNext, 5000)
return () => clearTimeout(timer.current)
}, [current, timer.current])
timer.current = setTimeout(onNext, 5000);
return () => clearTimeout(timer.current);
}, [current, timer.current]);
useEffect(() => {
if (loaded.length === 1) onNext()
}, [loaded])
if (loaded.length === 1) onNext();
}, [loaded]);
return (
<div className={styles.wrap}>
<div className={styles.loaders}>
{
heroes.slice(0, items).map((hero, i) => (
<img src={getURL({ url: hero.thumbnail }, preset)} key={hero.id} onLoad={() => onLoad(i)} />
))
}
{heroes.slice(0, items).map((hero, i) => (
<img
src={getURL({ url: hero.thumbnail }, preset)}
key={hero.id}
onLoad={() => onLoad(i)}
/>
))}
</div>
{loaded.length > 0 && (
@ -87,10 +95,7 @@ const FlowHeroUnconnected: FC<IProps> = ({ heroes }) => {
key={hero.id}
onClick={goToNode}
>
<img
src={getURL({ url: hero.thumbnail }, preset)}
alt={hero.thumbnail}
/>
<img src={getURL({ url: hero.thumbnail }, preset)} alt={hero.thumbnail} />
</div>
))}
</div>

View file

@ -4,19 +4,11 @@ import { describeArc } from '~/utils/dom';
interface IProps {
size: number;
progress: number;
progress?: number;
}
export const ArcProgress: FC<IProps> = ({ size, progress }) => (
export const ArcProgress: FC<IProps> = ({ size, progress = 0 }) => (
<svg className={styles.icon} width={size} height={size}>
<path
d={describeArc(
size / 2,
size / 2,
size / 2 - 2,
360 * (1 - progress),
360,
)}
/>
<path d={describeArc(size / 2, size / 2, size / 2 - 2, 360 * (1 - progress), 360)} />
</svg>
);

View file

@ -1,8 +1,16 @@
import classnames from 'classnames';
import React, { ButtonHTMLAttributes, DetailedHTMLProps, FC, createElement, memo } from 'react';
import React, {
ButtonHTMLAttributes,
DetailedHTMLProps,
FC,
createElement,
memo,
useRef,
} from 'react';
import styles from './styles.module.scss';
import { Icon } from '~/components/input/Icon';
import { IIcon } from '~/redux/types';
import { usePopper } from 'react-popper';
type IButtonProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
@ -19,6 +27,7 @@ type IButtonProps = DetailedHTMLProps<
is_loading?: boolean;
stretchy?: boolean;
iconOnly?: boolean;
label?: string;
};
const Button: FC<IButtonProps> = memo(
@ -37,9 +46,24 @@ const Button: FC<IButtonProps> = memo(
stretchy,
disabled,
iconOnly,
label,
ref,
...props
}) =>
createElement(
}) => {
const tooltip = useRef<HTMLSpanElement | null>(null);
const pop = usePopper(tooltip?.current?.parentElement, tooltip.current, {
placement: 'top',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 5],
},
},
],
});
return createElement(
seamless || non_submitting ? 'div' : 'button',
{
className: classnames(styles.button, className, styles[size], styles[color], {
@ -58,8 +82,14 @@ const Button: FC<IButtonProps> = memo(
iconLeft && <Icon icon={iconLeft} size={20} key={0} className={styles.icon_left} />,
title ? <span>{title}</span> : children || null,
iconRight && <Icon icon={iconRight} size={20} key={2} className={styles.icon_right} />,
!!label && (
<span ref={tooltip} className={styles.tooltip} style={pop.styles.popper} key="tooltip">
{label}
</span>
),
]
)
);
}
);
export { Button };

View file

@ -10,6 +10,7 @@
}
.button {
position: relative;
height: $input_height;
border: none;
box-sizing: border-box;
@ -177,17 +178,6 @@
fill: white;
}
}
> * {
margin: 0 5px;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
}
.micro {
@ -235,3 +225,21 @@
width: 20px;
height: 20px;
}
.tooltip {
padding: 5px 10px;
background-color: darken($content_bg, 4%);
z-index: 2;
border-radius: $input_radius;
text-transform: none;
opacity: 0;
pointer-events: none;
touch-action: none;
transition: opacity 0.1s;
border: 1px solid transparentize(white, 0.9);
.button:hover & {
opacity: 1;
font: $font_14_semibold;
}
}

View file

@ -1,6 +1,9 @@
import React, { HTMLAttributes } from 'react';
import styles from './styles.module.scss';
import classNames from 'classnames';
type IProps = HTMLAttributes<HTMLDivElement> & {};
export const ButtonGroup = ({ children }: IProps) => <div className={styles.wrap}>{children}</div>;
export const ButtonGroup = ({ children, className }: IProps) => (
<div className={classNames(styles.wrap, className)}>{children}</div>
);

View file

@ -1,9 +1,10 @@
import React, { FC, ChangeEvent, useCallback, useState, useEffect, LegacyRef } from 'react';
import React, { ChangeEvent, FC, useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import styles from '~/styles/common/inputs.module.scss';
import { Icon } from '~/components/input/Icon';
import { IInputTextProps } from '~/redux/types';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { useTranslatedError } from '~/utils/hooks/useTranslatedError';
const InputText: FC<IInputTextProps> = ({
wrapperClassName,
@ -20,16 +21,24 @@ const InputText: FC<IInputTextProps> = ({
...props
}) => {
const [focused, setFocused] = useState(false);
const [inner_ref, setInnerRef] = useState<HTMLInputElement>(null);
const [inner_ref, setInnerRef] = useState<HTMLInputElement | null>(null);
const onInput = useCallback(
({ target }: ChangeEvent<HTMLInputElement>) => handler(target.value),
({ target }: ChangeEvent<HTMLInputElement>) => {
if (!handler) {
return;
}
handler(target.value);
},
[handler]
);
const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(() => setFocused(false), []);
const translatedError = useTranslatedError(error);
useEffect(() => {
if (onRef) onRef(inner_ref);
}, [inner_ref, onRef]);
@ -80,9 +89,9 @@ const InputText: FC<IInputTextProps> = ({
</div>
)}
{error && (
{!!translatedError && (
<div className={styles.error}>
<span>{error}</span>
<span>{translatedError}</span>
</div>
)}

View file

@ -1,6 +1,6 @@
import React, {
ChangeEvent,
LegacyRef,
DetailedHTMLProps,
memo,
TextareaHTMLAttributes,
useCallback,
@ -14,7 +14,10 @@ import autosize from 'autosize';
import styles from '~/styles/common/inputs.module.scss';
import { Icon } from '../Icon';
type IProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
type IProps = DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
> & {
value: string;
placeholder?: string;
rows?: number;
@ -26,6 +29,7 @@ type IProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
status?: 'error' | 'success' | '';
title?: string;
seamless?: boolean;
setRef?: (r: HTMLTextAreaElement) => void;
};
const Textarea = memo<IProps>(
@ -40,12 +44,12 @@ const Textarea = memo<IProps>(
status = '',
seamless,
value,
setRef,
...props
}) => {
const [rows, setRows] = useState(minRows || 1);
const [focused, setFocused] = useState(false);
const textarea: LegacyRef<HTMLTextAreaElement> = useRef(null);
const ref = useRef<HTMLTextAreaElement>(null);
const onInput = useCallback(
({ target }: ChangeEvent<HTMLTextAreaElement>) => handler(target.value),
@ -56,12 +60,23 @@ const Textarea = memo<IProps>(
const onBlur = useCallback(() => setFocused(false), [setFocused]);
useEffect(() => {
if (!textarea.current) return;
const target = ref?.current;
if (!target) return;
autosize(textarea.current);
autosize(target);
return () => autosize.destroy(textarea.current);
}, [textarea.current]);
if (setRef) {
setRef(target);
}
return () => autosize.destroy(target);
}, [ref, setRef]);
useEffect(() => {
if (!ref.current) return;
autosize.update(ref.current);
}, [value]);
return (
<div
@ -80,11 +95,11 @@ const Textarea = memo<IProps>(
placeholder={placeholder}
className={classNames(styles.textarea, className)}
onChange={onInput}
ref={textarea}
ref={ref}
onFocus={onFocus}
onBlur={onBlur}
style={{
maxHeight: maxRows * 20,
// maxHeight: maxRows * 20,
minHeight: minRows * 20,
}}
{...props}

View file

@ -34,6 +34,10 @@ export class GodRays extends React.Component<IGodRaysProps> {
const ctx = this.canvas.getContext('2d');
if (!ctx) {
return;
}
ctx.globalCompositeOperation = 'luminosity';
ctx.clearRect(0, 0, width, height + 100); // clear canvas
ctx.save();
@ -123,7 +127,7 @@ export class GodRays extends React.Component<IGodRaysProps> {
);
}
canvas: HTMLCanvasElement;
canvas: HTMLCanvasElement | null | undefined;
inc;
}

View file

@ -42,8 +42,12 @@ const NotificationsUnconnected: FC<IProps> = ({
(notification: INotification) => {
switch (notification.type) {
case 'message':
if (!(notification as IMessageNotification)?.content?.from?.username) {
return;
}
return authOpenProfile(
(notification as IMessageNotification).content.from.username,
(notification as IMessageNotification).content.from!.username,
'messages'
);
default:
@ -78,9 +82,6 @@ const NotificationsUnconnected: FC<IProps> = ({
);
};
const Notifications = connect(
mapStateToProps,
mapDispatchToProps
)(NotificationsUnconnected);
const Notifications = connect(mapStateToProps, mapDispatchToProps)(NotificationsUnconnected);
export { Notifications };

View file

@ -15,10 +15,12 @@ interface IProps {
const UserButton: FC<IProps> = ({ user: { username, photo }, authOpenProfile, onLogout }) => {
const onProfileOpen = useCallback(() => {
if (!username) return;
authOpenProfile(username, 'profile');
}, [authOpenProfile, username]);
const onSettingsOpen = useCallback(() => {
if (!username) return;
authOpenProfile(username, 'settings');
}, [authOpenProfile, username]);

View file

@ -26,7 +26,7 @@ type Props = ReturnType<typeof mapStateToProps> &
file: IFile;
isEditing?: boolean;
onDelete?: (id: IFile['id']) => void;
onTitleChange?: (file_id: IFile['id'], title: IFile['metadata']['title']) => void;
onTitleChange?: (file_id: IFile['id'], title: string) => void;
};
const AudioPlayerUnconnected = memo(
@ -93,14 +93,18 @@ const AudioPlayerUnconnected = memo(
[file.metadata]
);
const onRename = useCallback((val: string) => onTitleChange(file.id, val), [
onTitleChange,
file.id,
]);
const onRename = useCallback(
(val: string) => {
if (!onTitleChange) return;
onTitleChange(file.id, val);
},
[onTitleChange, file.id]
);
useEffect(() => {
const active = current && current.id === file.id;
setPlaying(current && current.id === file.id);
setPlaying(!!current && current.id === file.id);
if (active) Player.on('playprogress', onProgress);

View file

@ -1,12 +1,11 @@
import React, { FC, useCallback } from 'react';
import styles from './styles.module.scss';
import { Button } from '~/components/input/Button';
import { nodeLockComment } from '~/redux/node/actions';
import { IComment } from '~/redux/types';
interface IProps {
id: IComment['id'];
onDelete: typeof nodeLockComment;
onDelete: (id: IComment['id'], isLocked: boolean) => void;
}
const CommendDeleted: FC<IProps> = ({ id, onDelete }) => {

View file

@ -19,7 +19,10 @@ const ImageSwitcher: FC<IProps> = ({ total, current, onChange, loaded }) => {
<div className={styles.switcher}>
{range(0, total).map(item => (
<div
className={classNames({ is_active: item === current, is_loaded: loaded[item] })}
className={classNames({
is_active: item === current,
is_loaded: loaded && loaded[item],
})}
key={item}
onClick={() => onChange(item)}
/>

View file

@ -1,32 +1,23 @@
import React, { FC, useCallback, KeyboardEventHandler, useEffect, useMemo } from 'react';
import { Textarea } from '~/components/input/Textarea';
import React, { FC } from 'react';
import { CommentWrapper } from '~/components/containers/CommentWrapper';
import styles from './styles.module.scss';
import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button';
import { assocPath } from 'ramda';
import { InputHandler, IFileWithUUID, IFile } from '~/redux/types';
import { connect } from 'react-redux';
import * as NODE_ACTIONS from '~/redux/node/actions';
import { selectNode } from '~/redux/node/selectors';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { selectUser, selectAuthUser } from '~/redux/auth/selectors';
import { CommentForm } from '../../comment/CommentForm';
import { selectAuthUser } from '~/redux/auth/selectors';
import { CommentForm } from '~/components/comment/CommentForm';
import { INode } from '~/redux/types';
const mapStateToProps = state => ({
user: selectAuthUser(state),
});
type IProps = ReturnType<typeof mapStateToProps> & {
is_before?: boolean;
isBefore?: boolean;
nodeId: INode['id'];
};
const NodeCommentFormUnconnected: FC<IProps> = ({ user, is_before }) => {
const NodeCommentFormUnconnected: FC<IProps> = ({ user, isBefore, nodeId }) => {
return (
<CommentWrapper user={user}>
<CommentForm id={0} is_before={is_before} />
<CommentForm nodeId={nodeId} />
</CommentWrapper>
);
};

View file

@ -1,89 +1,73 @@
import React, { FC, useMemo, memo } from 'react';
import React, { FC, memo, useCallback, useMemo } from 'react';
import { Comment } from '../../comment/Comment';
import { Filler } from '~/components/containers/Filler';
import styles from './styles.module.scss';
import { ICommentGroup, IComment } from '~/redux/types';
import { IComment, ICommentGroup, IFile } from '~/redux/types';
import { groupCommentsByUser } from '~/utils/fn';
import { IUser } from '~/redux/auth/types';
import { canEditComment } from '~/utils/node';
import { nodeLockComment, nodeEditComment, nodeLoadMoreComments } from '~/redux/node/actions';
import { nodeLoadMoreComments, nodeLockComment } from '~/redux/node/actions';
import { INodeState } from '~/redux/node/reducer';
import { COMMENTS_DISPLAY } from '~/redux/node/constants';
import { plural } from '~/utils/dom';
import * as MODAL_ACTIONS from '~/redux/modal/actions';
import { modalShowPhotoswipe } from '~/redux/modal/actions';
import { useDispatch } from 'react-redux';
interface IProps {
comments?: IComment[];
comment_data: INodeState['comment_data'];
comment_count: INodeState['comment_count'];
comments: IComment[];
count: INodeState['comment_count'];
user: IUser;
onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
onLoadMore: typeof nodeLoadMoreComments;
order?: 'ASC' | 'DESC';
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
}
const NodeComments: FC<IProps> = memo(
({
comments,
comment_data,
user,
onDelete,
onEdit,
onLoadMore,
comment_count = 0,
order = 'DESC',
modalShowPhotoswipe,
}) => {
const comments_left = useMemo(() => Math.max(0, comment_count - comments.length), [
comments,
comment_count,
]);
const NodeComments: FC<IProps> = memo(({ comments, user, count = 0, order = 'DESC' }) => {
const dispatch = useDispatch();
const left = useMemo(() => Math.max(0, count - comments.length), [comments, count]);
const groupped: ICommentGroup[] = useMemo(
() => (order === 'DESC' ? [...comments].reverse() : comments).reduce(groupCommentsByUser, []),
[comments, order]
);
const groupped: ICommentGroup[] = useMemo(
() => (order === 'DESC' ? [...comments].reverse() : comments).reduce(groupCommentsByUser, []),
[comments, order]
);
const more = useMemo(
() =>
comments_left > 0 && (
<div className={styles.more} onClick={onLoadMore}>
Показать ещё{' '}
{plural(
Math.min(comments_left, COMMENTS_DISPLAY),
'комментарий',
'комментария',
'комментариев'
)}
{comments_left > COMMENTS_DISPLAY ? ` из ${comments_left} оставшихся` : ''}
</div>
),
[comments_left, onLoadMore, COMMENTS_DISPLAY]
);
const onDelete = useCallback(
(id: IComment['id'], locked: boolean) => dispatch(nodeLockComment(id, locked)),
[dispatch]
);
const onLoadMoreComments = useCallback(() => dispatch(nodeLoadMoreComments()), [dispatch]);
const onShowPhotoswipe = useCallback(
(images: IFile[], index: number) => dispatch(modalShowPhotoswipe(images, index)),
[dispatch]
);
return (
<div className={styles.wrap}>
{order === 'DESC' && more}
const more = useMemo(
() =>
left > 0 && (
<div className={styles.more} onClick={onLoadMoreComments}>
Показать ещё{' '}
{plural(Math.min(left, COMMENTS_DISPLAY), 'комментарий', 'комментария', 'комментариев')}
{left > COMMENTS_DISPLAY ? ` из ${left} оставшихся` : ''}
</div>
),
[left, onLoadMoreComments]
);
{groupped.map(group => (
<Comment
key={group.ids.join()}
comment_group={group}
comment_data={comment_data}
can_edit={canEditComment(group, user)}
onDelete={onDelete}
onEdit={onEdit}
modalShowPhotoswipe={modalShowPhotoswipe}
/>
))}
return (
<div className={styles.wrap}>
{order === 'DESC' && more}
{order === 'ASC' && more}
</div>
);
}
);
{groupped.map(group => (
<Comment
key={group.ids.join()}
comment_group={group}
can_edit={canEditComment(group, user)}
onDelete={onDelete}
modalShowPhotoswipe={onShowPhotoswipe}
/>
))}
{order === 'ASC' && more}
</div>
);
});
export { NodeComments };

View file

@ -1,11 +1,10 @@
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './styles.module.scss';
import classNames from 'classnames';
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { INodeComponentProps } from '~/redux/node/constants';
import { getURL } from '~/utils/dom';
import { PRESETS } from '~/constants/urls';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { throttle } from 'throttle-debounce';
import { Icon } from '~/components/input/Icon';
import { useArrows } from '~/utils/hooks/keys';
@ -37,8 +36,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
const [is_dragging, setIsDragging] = useState(false);
const [drag_start, setDragStart] = useState(0);
const slide = useRef<HTMLDivElement>();
const wrap = useRef<HTMLDivElement>();
const slide = useRef<HTMLDivElement>(null);
const wrap = useRef<HTMLDivElement>(null);
const setHeightThrottled = useCallback(throttle(100, setHeight), [setHeight]);
@ -222,6 +221,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
const changeCurrent = useCallback(
(item: number) => {
if (!wrap.current) return;
const { width } = wrap.current.getBoundingClientRect();
setOffset(-1 * item * width);
},
@ -267,10 +268,10 @@ const NodeImageSlideBlock: FC<IProps> = ({
[styles.is_active]: index === current,
})}
ref={setRef(index)}
key={node.updated_at + file.id}
key={`${node?.updated_at || ''} + ${file?.id || ''} + ${index}`}
>
<svg
viewBox={`0 0 ${file.metadata.width} ${file.metadata.height}`}
viewBox={`0 0 ${file?.metadata?.width || 0} ${file?.metadata?.height || 0}`}
className={classNames(styles.preview, { [styles.is_loaded]: loaded[index] })}
style={{
maxHeight: max_height,
@ -279,19 +280,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
>
<defs>
<filter id="f1" x="0" y="0">
<feBlend
mode="multiply"
x="0%"
y="0%"
width="100%"
height="100%"
in="SourceGraphic"
in2="SourceGraphic"
result="blend"
/>
<feGaussianBlur
stdDeviation="15 15"
stdDeviation="5 5"
x="0%"
y="0%"
width="100%"

View file

@ -24,11 +24,11 @@ const NodePanel: FC<IProps> = memo(
({ node, layout, can_edit, can_like, can_star, is_loading, onEdit, onLike, onStar, onLock }) => {
const [stack, setStack] = useState(false);
const ref = useRef(null);
const ref = useRef<HTMLDivElement>(null);
const getPlace = useCallback(() => {
if (!ref.current) return;
const { bottom } = ref.current.getBoundingClientRect();
const { bottom } = ref.current!.getBoundingClientRect();
setStack(bottom > window.innerHeight);
}, [ref]);
@ -75,7 +75,7 @@ const NodePanel: FC<IProps> = memo(
can_edit={can_edit}
can_like={can_like}
can_star={can_star}
is_loading={is_loading}
is_loading={!!is_loading}
/>
</div>
);

View file

@ -96,7 +96,9 @@ const NodePanelInner: FC<IProps> = memo(
<Icon icon="heart" size={24} onClick={onLike} />
)}
{like_count > 0 && <div className={styles.like_count}>{like_count}</div>}
{!!like_count && like_count > 0 && (
<div className={styles.like_count}>{like_count}</div>
)}
</div>
)}
</div>

View file

@ -1,16 +1,16 @@
import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import styles from "./styles.module.scss";
import classNames from "classnames";
import { INode } from "~/redux/types";
import { PRESETS, URLS } from "~/constants/urls";
import { RouteComponentProps, withRouter } from "react-router";
import { getURL, stringToColour } from "~/utils/dom";
import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './styles.module.scss';
import classNames from 'classnames';
import { INode } from '~/redux/types';
import { PRESETS, URLS } from '~/constants/urls';
import { RouteComponentProps, withRouter } from 'react-router';
import { getURL, stringToColour } from '~/utils/dom';
type IProps = RouteComponentProps & {
item: Partial<INode>;
};
type CellSize = 'small' | 'medium' | 'large'
type CellSize = 'small' | 'medium' | 'large';
const getTitleLetters = (title: string): string => {
const words = (title && title.split(' ')) || [];
@ -43,17 +43,21 @@ const NodeRelatedItemUnconnected: FC<IProps> = memo(({ item, history }) => {
useEffect(() => {
if (!ref.current) return;
const cb = () => setWidth(ref.current.getBoundingClientRect().width)
const cb = () => setWidth(ref.current!.getBoundingClientRect().width);
window.addEventListener('resize', cb);
cb();
return () => window.removeEventListener('resize', cb);
}, [ref.current])
}, [ref.current]);
const size = useMemo<CellSize>(() => {
if (width > 90) return 'large';
if (width > 76) return 'medium';
return 'small';
}, [width])
}, [width]);
return (
<div

View file

@ -1,19 +1,26 @@
import React, { FC } from 'react';
import { INode } from '~/redux/types';
import React, { FC, useMemo } from 'react';
import { path } from 'ramda';
import { formatTextParagraphs } from '~/utils/dom';
import styles from './styles.module.scss';
import { INodeComponentProps } from '~/redux/node/constants';
import classNames from 'classnames';
import styles from './styles.module.scss';
import markdown from '~/styles/common/markdown.module.scss';
interface IProps extends INodeComponentProps {}
const NodeTextBlock: FC<IProps> = ({ node }) => (
<div
className={styles.text}
dangerouslySetInnerHTML={{
__html: formatTextParagraphs(path(['blocks', 0, 'text'], node)),
}}
/>
);
const NodeTextBlock: FC<IProps> = ({ node }) => {
const content = useMemo(() => formatTextParagraphs(path(['blocks', 0, 'text'], node) || ''), [
node.blocks,
]);
return (
<div
className={classNames(styles.text, markdown.wrapper)}
dangerouslySetInnerHTML={{
__html: content,
}}
/>
);
};
export { NodeTextBlock };

View file

@ -7,7 +7,7 @@ interface IProps extends INodeComponentProps {}
const NodeVideoBlock: FC<IProps> = ({ node }) => {
const video = useMemo(() => {
const url: string = path(['blocks', 0, 'url'], node);
const url: string = path(['blocks', 0, 'url'], node) || '';
const match =
url &&
url.match(

View file

@ -21,7 +21,7 @@ const NotificationMessage: FC<IProps> = ({
<div className={styles.item} onMouseDown={onMouseDown}>
<div className={styles.item_head}>
<Icon icon="message" />
<div className={styles.item_title}>Сообщение от ~{from.username}:</div>
<div className={styles.item_title}>Сообщение от ~{from?.username}:</div>
</div>
<div className={styles.item_text}>{text}</div>
</div>

View file

@ -9,6 +9,7 @@ import { CommentMenu } from '~/components/comment/CommentMenu';
import { MessageForm } from '~/components/profile/MessageForm';
import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button';
import markdown from '~/styles/common/markdown.module.scss';
interface IProps {
message: IMessage;
@ -66,7 +67,10 @@ const Message: FC<IProps> = ({
) : (
<div className={styles.text}>
{!incoming && <CommentMenu onEdit={onEditClicked} onDelete={onDeleteClicked} />}
<Group dangerouslySetInnerHTML={{ __html: formatText(message.text) }} />
<Group
dangerouslySetInnerHTML={{ __html: formatText(message.text) }}
className={markdown.wrapper}
/>
</div>
)}

View file

@ -9,6 +9,7 @@ $outgoing_color: $comment_bg;
flex-direction: row;
padding: 0 0 0 42px;
position: relative;
word-break: break-word;
.avatar {
// margin: 0 0 0 10px;

View file

@ -39,7 +39,7 @@ const MessageFormUnconnected: FC<IProps> = ({
const onSuccess = useCallback(() => {
setText('');
if (isEditing) {
if (isEditing && onCancel) {
onCancel();
}
}, [setText, isEditing, onCancel]);
@ -50,7 +50,7 @@ const MessageFormUnconnected: FC<IProps> = ({
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
({ ctrlKey, key }) => {
if (!!ctrlKey && key === 'Enter') onSubmit();
if (ctrlKey && key === 'Enter') onSubmit();
},
[onSubmit]
);

View file

@ -5,6 +5,8 @@ import { connect } from 'react-redux';
import { selectAuthProfile } from '~/redux/auth/selectors';
import { ProfileLoader } from '~/containers/profile/ProfileLoader';
import { Group } from '~/components/containers/Group';
import markdown from '~/styles/common/markdown.module.scss';
import classNames from 'classnames';
const mapStateToProps = state => ({
profile: selectAuthProfile(state),
@ -17,15 +19,15 @@ const ProfileDescriptionUnconnected: FC<IProps> = ({ profile: { user, is_loading
return (
<div className={styles.wrap}>
{user.description && (
{!!user?.description && (
<Group
className={styles.content}
className={classNames(styles.content, markdown.wrapper)}
dangerouslySetInnerHTML={{ __html: formatText(user.description) }}
/>
)}
{!user.description && (
{!user?.description && (
<div className={styles.placeholder}>
{user.fullname || user.username} пока ничего не рассказал о себе
{user?.fullname || user?.username} пока ничего не рассказал о себе
</div>
)}
</div>

View file

@ -3,7 +3,7 @@ import { ITag } from '~/redux/types';
import { TagWrapper } from '~/components/tags/TagWrapper';
const getTagFeature = (tag: Partial<ITag>) => {
if (tag.title.substr(0, 1) === '/') return 'green';
if (tag?.title?.substr(0, 1) === '/') return 'green';
return '';
};

View file

@ -87,7 +87,10 @@ const TagAutocompleteUnconnected: FC<Props> = ({
useEffect(() => {
tagSetAutocomplete({ options: [] });
return () => tagSetAutocomplete({ options: [] });
return () => {
tagSetAutocomplete({ options: [] });
};
}, [tagSetAutocomplete]);
useEffect(() => {

View file

@ -77,6 +77,10 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(
event => {
if (!wrapper.current || !ref.current) {
return;
}
if (wrapper.current.contains(event.target)) {
ref.current.focus();
return;
@ -126,7 +130,7 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
/>
</TagWrapper>
{onInput && focused && input?.length > 0 && (
{onInput && focused && input?.length > 0 && ref.current && (
<TagAutocomplete
exclude={exclude}
input={ref.current}

View file

@ -20,14 +20,18 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
const onSubmit = useCallback(
(last: string[]) => {
if (!onTagsChange) {
return;
}
const exist = tags.map(tag => tag.title);
onTagsChange(uniq([...exist, ...data, ...last]));
onTagsChange(uniq([...exist, ...data, ...last]).filter(el => el) as string[]);
},
[data]
);
useEffect(() => {
setData(data.filter(title => !tags.some(tag => tag.title.trim() === title.trim())));
setData(data.filter(title => !tags.some(tag => tag?.title?.trim() === title.trim())));
}, [tags]);
const onAppendTag = useCallback(
@ -44,10 +48,10 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
return last;
}, [data, setData]);
const exclude = useMemo(() => [...(data || []), ...(tags || []).map(({ title }) => title)], [
data,
tags,
]);
const exclude = useMemo(
() => [...(data || []), ...(tags || []).filter(el => el.title).map(({ title }) => title!)],
[data, tags]
);
return (
<TagField {...props}>