1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 04:46:40 +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

5
.env
View file

@ -1,5 +0,0 @@
#API_HOST = http://localhost:7777/
#REMOTE_CURRENT = http://localhost:7777/static/
REACT_APP_API_HOST = https://pig.staging.vault48.org/
REACT_APP_REMOTE_CURRENT = https://pig.staging.vault48.org/static/
EXPOSE = 4000

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/npm-debug.log /npm-debug.log
/.idea /.idea
/dist /dist
/.env

View file

@ -1,4 +1,5 @@
const CracoAlias = require('craco-alias'); const CracoAlias = require('craco-alias');
const fastRefreshCracoPlugin = require('craco-fast-refresh');
module.exports = { module.exports = {
webpack: { webpack: {
@ -14,27 +15,28 @@ module.exports = {
mode: 'file', mode: 'file',
}, },
jest: { jest: {
setupTestFrameworkScriptFile: "<rootDir>/src/setupTests.js", setupTestFrameworkScriptFile: '<rootDir>/src/setupTests.js',
configure: { configure: {
moduleNameMapper: { moduleNameMapper: {
"^~/(.*)$": "<rootDir>/src/$1", '^~/(.*)$': '<rootDir>/src/$1',
"^.+\\.scss$": "identity-obj-proxy" '^.+\\.scss$': 'identity-obj-proxy',
}, },
snapshotSerializers: ["enzyme-to-json/serializer"], snapshotSerializers: ['enzyme-to-json/serializer'],
moduleFileExtensions: ["js", "json", "ts", "tsx", "jsx", "node"], moduleFileExtensions: ['js', 'json', 'ts', 'tsx', 'jsx', 'node'],
verbose: true, verbose: true,
roots: ["<rootDir>/src"], roots: ['<rootDir>/src'],
transform: { transform: {
"^.+\\.tsx?$": "ts-jest", '^.+\\.tsx?$': 'ts-jest',
"^.+\\.ts?$": "babel-jest", '^.+\\.ts?$': 'babel-jest',
"^.+\\.js?$": "ts-jest", '^.+\\.js?$': 'ts-jest',
"^.+\\.jsx?$": "babel-jest" '^.+\\.jsx?$': 'babel-jest',
}, },
preset: "ts-jest/presets/js-with-ts", preset: 'ts-jest/presets/js-with-ts',
testEnvironment: "node" testEnvironment: 'node',
} },
}, },
plugins: [ plugins: [
{ plugin: fastRefreshCracoPlugin },
{ {
plugin: CracoAlias, plugin: CracoAlias,
options: { options: {

View file

@ -8,12 +8,15 @@
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"autosize": "^4.0.2", "autosize": "^4.0.2",
"axios": "^0.18.0", "axios": "^0.21.1",
"body-scroll-lock": "^2.6.4", "body-scroll-lock": "^2.6.4",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"connected-react-router": "^6.5.2", "connected-react-router": "^6.5.2",
"date-fns": "^2.4.1", "date-fns": "^2.4.1",
"flexbin": "^0.2.0", "flexbin": "^0.2.0",
"formik": "^2.2.6",
"insane": "^2.6.2",
"marked": "^2.0.0",
"node-sass": "4.14.1", "node-sass": "4.14.1",
"photoswipe": "^4.1.3", "photoswipe": "^4.1.3",
"raleway-cyrillic": "^4.0.2", "raleway-cyrillic": "^4.0.2",
@ -21,7 +24,7 @@
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-popper": "^2.2.3", "react-popper": "^2.2.3",
"react-redux": "^6.0.1", "react-redux": "^7.2.2",
"react-router": "^5.1.2", "react-router": "^5.1.2",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-scripts": "3.4.4", "react-scripts": "3.4.4",
@ -34,18 +37,18 @@
"throttle-debounce": "^2.1.0", "throttle-debounce": "^2.1.0",
"typescript": "^4.0.5", "typescript": "^4.0.5",
"uuid4": "^1.1.4", "uuid4": "^1.1.4",
"web-vitals": "^0.2.4" "web-vitals": "^0.2.4",
"yup": "^0.32.9"
}, },
"scripts": { "scripts": {
"start": "craco start", "start": "craco start",
"build": "craco build", "build": "craco build",
"test": "craco test", "test": "craco test",
"eject": "craco eject" "ts-check": "tsc -p tsconfig.json --noEmit"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app"
"react-app/jest"
] ]
}, },
"browserslist": { "browserslist": {
@ -61,12 +64,15 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@craco/craco": "5.8.0",
"@types/classnames": "^2.2.7", "@types/classnames": "^2.2.7",
"@types/marked": "^1.2.2",
"@types/node": "^11.13.22", "@types/node": "^11.13.22",
"@types/ramda": "^0.26.33", "@types/ramda": "^0.26.33",
"@types/react-redux": "^7.1.11", "@types/react-redux": "^7.1.11",
"@craco/craco": "5.8.0", "@types/yup": "^0.29.11",
"craco-alias": "^2.1.1", "craco-alias": "^2.1.1",
"craco-fast-refresh": "^1.0.2",
"prettier": "^1.18.2" "prettier": "^1.18.2"
} }
} }

View file

@ -1,11 +1,8 @@
import React, { FC, HTMLAttributes, memo } from 'react'; import React, { FC, HTMLAttributes, memo } from 'react';
import { CommentWrapper } from '~/components/containers/CommentWrapper'; import { CommentWrapper } from '~/components/containers/CommentWrapper';
import { ICommentGroup } from '~/redux/types'; import { IComment, ICommentGroup } from '~/redux/types';
import { CommentContent } from '~/components/comment/CommentContent'; import { CommentContent } from '~/components/comment/CommentContent';
import styles from './styles.module.scss'; 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 { CommendDeleted } from '../../node/CommendDeleted';
import * as MODAL_ACTIONS from '~/redux/modal/actions'; import * as MODAL_ACTIONS from '~/redux/modal/actions';
@ -13,25 +10,21 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
is_empty?: boolean; is_empty?: boolean;
is_loading?: boolean; is_loading?: boolean;
comment_group: ICommentGroup; comment_group: ICommentGroup;
comment_data: INodeState['comment_data'];
is_same?: boolean; is_same?: boolean;
can_edit?: boolean; can_edit?: boolean;
onDelete: typeof nodeLockComment; onDelete: (id: IComment['id'], isLocked: boolean) => void;
onEdit: typeof nodeEditComment;
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
}; };
const Comment: FC<IProps> = memo( const Comment: FC<IProps> = memo(
({ ({
comment_group, comment_group,
comment_data,
is_empty, is_empty,
is_same, is_same,
is_loading, is_loading,
className, className,
can_edit, can_edit,
onDelete, onDelete,
onEdit,
modalShowPhotoswipe, modalShowPhotoswipe,
...props ...props
}) => { }) => {
@ -50,17 +43,12 @@ const Comment: FC<IProps> = memo(
return <CommendDeleted id={comment.id} onDelete={onDelete} key={comment.id} />; 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 ( return (
<CommentContent <CommentContent
comment={comment} comment={comment}
key={comment.id} key={comment.id}
can_edit={!!can_edit} can_edit={!!can_edit}
onDelete={onDelete} onDelete={onDelete}
onEdit={onEdit}
modalShowPhotoswipe={modalShowPhotoswipe} 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 { IComment, IFile } from '~/redux/types';
import { path } from 'ramda'; import { append, assocPath, path } from 'ramda';
import { formatCommentText, getURL, getPrettyDate } 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 { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { assocPath } from 'ramda';
import { append } from 'ramda';
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';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
import { CommentMenu } from '../CommentMenu'; import { CommentMenu } from '../CommentMenu';
import * as MODAL_ACTIONS from '~/redux/modal/actions'; 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 { interface IProps {
comment: IComment; comment: IComment;
can_edit: boolean; can_edit: boolean;
onDelete: typeof nodeLockComment; onDelete: (id: IComment['id'], isLocked: boolean) => void;
onEdit: typeof nodeEditComment;
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
} }
const CommentContent: FC<IProps> = memo( const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, modalShowPhotoswipe }) => {
({ comment, can_edit, onDelete, onEdit, modalShowPhotoswipe }) => { const [isEditing, setIsEditing] = useState(false);
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>( const { current } = useShallowSelect(selectNode);
() =>
reduce(
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
{},
comment.files
),
[comment]
);
const onLockClick = useCallback(() => { const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
onDelete(comment.id, !comment.deleted_at); const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]);
}, [comment, onDelete]);
const onEditClick = useCallback(() => { const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
onEdit(comment.id); () =>
}, [comment, onEdit]); reduce(
(group, file) =>
file.type ? assocPath([file.type], append(file, group[file.type]), group) : group,
{},
comment.files
),
[comment]
);
const menu = useMemo( const onLockClick = useCallback(() => {
() => can_edit && <CommentMenu onDelete={onLockClick} onEdit={onEditClick} />, onDelete(comment.id, !comment.deleted_at);
[can_edit, comment, onEditClick, onLockClick] }, [comment, onDelete]);
);
return ( const menu = useMemo(
<div className={styles.wrap}> () => can_edit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />,
{comment.text && ( [can_edit, startEditing, onLockClick]
<Group className={classnames(styles.block, styles.block_text)}> );
{menu}
<Group className={styles.renderers}> const blocks = useMemo(
{formatCommentText(path(['user', 'username'], comment), comment.text).map( () =>
(block, key) => !!comment.text.trim()
COMMENT_BLOCK_RENDERERS[block.type] && ? formatCommentText(path(['user', 'username'], comment), comment.text)
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) : [],
)} [comment]
</Group> );
<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> </Group>
)}
{groupped.image && groupped.image.length > 0 && ( <div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
<div className={classnames(styles.block, styles.block_image)}> </Group>
{menu} )}
<div className={styles.images}> {groupped.image && groupped.image.length > 0 && (
{groupped.image.map((file, index) => ( <div className={classnames(styles.block, styles.block_image)}>
<div key={file.id} onClick={() => modalShowPhotoswipe(groupped.image, index)}> {menu}
<img src={getURL(file, PRESETS['600'])} alt={file.name} />
</div>
))}
</div>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div> <div className={styles.images}>
</div> {groupped.image.map((file, index) => (
)} <div key={file.id} onClick={() => modalShowPhotoswipe(groupped.image, index)}>
<img src={getURL(file, PRESETS['600'])} alt={file.name} />
{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> </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 }; export { CommentContent };

View file

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

View file

@ -1,234 +1,103 @@
import React, { FC, KeyboardEventHandler, memo, useCallback, useEffect, useMemo } from 'react'; import React, { FC, useCallback, useState } from 'react';
import { Textarea } from '~/components/input/Textarea'; import { useCommentFormFormik } from '~/utils/hooks/useCommentFormFormik';
import styles from './styles.module.scss'; import { FormikProvider } from 'formik';
import { Filler } from '~/components/containers/Filler'; import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import assocPath from 'ramda/es/assocPath'; import { FileUploaderProvider, useFileUploader } from '~/utils/hooks/fileUploader';
import { IComment, IFileWithUUID, InputHandler } from '~/redux/types'; import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
import { connect } from 'react-redux'; import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons';
import * as NODE_ACTIONS from '~/redux/node/actions'; import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons';
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 { CommentFormAttaches } from '~/components/comment/CommentFormAttaches'; 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 { 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) => ({ interface IProps {
node: selectNode(state), comment?: IComment;
uploads: selectUploads(state), nodeId: INode['id'];
}); onCancelEdit?: () => void;
}
const mapDispatchToProps = { const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => {
nodePostComment: NODE_ACTIONS.nodePostComment, const [textarea, setTextarea] = useState<HTMLTextAreaElement>();
nodeCancelCommentEdit: NODE_ACTIONS.nodeCancelCommentEdit, const uploader = useFileUploader(
nodeSetCommentData: NODE_ACTIONS.nodeSetCommentData, UPLOAD_SUBJECTS.COMMENT,
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, 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> & export { CommentForm };
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 };

View file

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

View file

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

View file

@ -28,15 +28,4 @@
:global(.green) { :global(.green) {
color: $wisegreen; 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 React, { FC, useState, useCallback, useEffect, useRef } from 'react';
import { IUser } from "~/redux/auth/types"; import { IUser } from '~/redux/auth/types';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
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';
interface IProps { interface IProps {
cover: IUser["cover"]; cover: IUser['cover'];
} }
const CoverBackdrop: FC<IProps> = ({ cover }) => { const CoverBackdrop: FC<IProps> = ({ cover }) => {
const ref = useRef<HTMLImageElement>(); const ref = useRef<HTMLImageElement>(null);
const [is_loaded, setIsLoaded] = useState(false); const [is_loaded, setIsLoaded] = useState(false);
@ -21,7 +21,7 @@ const CoverBackdrop: FC<IProps> = ({ cover }) => {
useEffect(() => { useEffect(() => {
if (!cover || !cover.url || !ref || !ref.current) return; if (!cover || !cover.url || !ref || !ref.current) return;
ref.current.src = ""; ref.current.src = '';
setIsLoaded(false); setIsLoaded(false);
ref.current.src = getURL(cover, PRESETS.cover); ref.current.src = getURL(cover, PRESETS.cover);
}, [cover]); }, [cover]);

View file

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

View file

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

View file

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

View file

@ -35,7 +35,7 @@ const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => {
); );
const onTitleChange = useCallback( const onTitleChange = useCallback(
(changeId: IFile['id'], title: IFile['metadata']['title']) => { (changeId: IFile['id'], title: string) => {
setFiles( setFiles(
files.map(file => files.map(file =>
file && file.id === changeId ? { ...file, metadata: { ...file.metadata, title } } : 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 styles from './styles.module.scss';
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants'; import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants';
import { has } from 'ramda';
interface IProps { interface IProps {
data: INode; data: INode;
@ -10,13 +11,19 @@ interface IProps {
setTemp: (val: string[]) => void; setTemp: (val: string[]) => void;
} }
const EditorPanel: FC<IProps> = ({ data, setData, temp, setTemp }) => ( const EditorPanel: FC<IProps> = ({ data, setData, temp, setTemp }) => {
<div className={styles.panel}> if (!data.type || !has(data.type, NODE_PANEL_COMPONENTS)) {
{NODE_PANEL_COMPONENTS[data.type] && return null;
NODE_PANEL_COMPONENTS[data.type].map((el, key) => }
createElement(el, { key, data, setData, temp, setTemp })
)} return (
</div> <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 }; 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]); setTemp([...temp, ...temps]);
uploadUploadFiles(items); uploadUploadFiles(items);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,11 +5,9 @@ import { path } from 'ramda';
import { InputText } from '~/components/input/InputText'; import { InputText } from '~/components/input/InputText';
import classnames from 'classnames'; import classnames from 'classnames';
import { getYoutubeThumb } from '~/utils/dom'; import { getYoutubeThumb } from '~/utils/dom';
import { NodeEditorProps } from '~/redux/node/types';
interface IProps { type IProps = NodeEditorProps & {};
data: INode;
setData: (val: INode) => void;
}
const VideoEditor: FC<IProps> = ({ data, setData }) => { const VideoEditor: FC<IProps> = ({ data, setData }) => {
const setUrl = useCallback( const setUrl = useCallback(
@ -19,9 +17,10 @@ const VideoEditor: FC<IProps> = ({ data, setData }) => {
const url = (path(['blocks', 0, 'url'], data) as string) || ''; const url = (path(['blocks', 0, 'url'], data) as string) || '';
const preview = useMemo(() => getYoutubeThumb(url), [url]); const preview = useMemo(() => getYoutubeThumb(url), [url]);
const backgroundImage = (preview && `url("${preview}")`) || '';
return ( return (
<div className={styles.preview} style={{ backgroundImage: preview && `url("${preview}")` }}> <div className={styles.preview} style={{ backgroundImage }}>
<div className={styles.input_wrap}> <div className={styles.input_wrap}>
<div className={classnames(styles.input, { active: !!preview })}> <div className={classnames(styles.input, { active: !!preview })}>
<InputText value={url} handler={setUrl} placeholder="Адрес видео" /> <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 { INode } from '~/redux/types';
import { formatCellText, getURL } from '~/utils/dom'; import { formatCellText, getURL } from '~/utils/dom';
import classNames from 'classnames'; import classNames from 'classnames';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import markdown from '~/styles/common/markdown.module.scss';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { flowSetCellView } from '~/redux/flow/actions'; import { flowSetCellView } from '~/redux/flow/actions';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { debounce } from 'throttle-debounce';
import { NODE_TYPES } from '~/redux/node/constants'; import { NODE_TYPES } from '~/redux/node/constants';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -33,43 +33,43 @@ const Cell: FC<IProps> = ({
}) => { }) => {
const ref = useRef(null); const ref = useRef(null);
const [is_loaded, setIsLoaded] = useState(false); const [is_loaded, setIsLoaded] = useState(false);
const [is_visible, setIsVisible] = useState(false); const [is_visible, setIsVisible] = useState(true);
const checkIfVisible = useCallback(() => { // const checkIfVisible = useCallback(() => {
if (!ref.current) return; // 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; // useEffect(() => {
const visibility = top + height > -600 && top < window.innerHeight + 600; // recalc visibility of other elements
if (visibility !== is_visible) setIsVisible(visibility); // window.dispatchEvent(new CustomEvent('scroll'));
}, [ref, is_visible, setIsVisible]); // }, [flow]);
const checkIfVisibleDebounced = useCallback(debounce(Math.random() * 100 + 100, checkIfVisible), [ // useEffect(() => {
checkIfVisible, // window.addEventListener('scroll', checkIfVisibleDebounced);
]); //
// return () => window.removeEventListener('scroll', checkIfVisibleDebounced);
useEffect(() => { // }, [checkIfVisibleDebounced]);
checkIfVisibleDebounced();
}, []);
useEffect(() => {
// recalc visibility of other elements
window.dispatchEvent(new CustomEvent('scroll'));
}, [flow]);
useEffect(() => {
window.addEventListener('scroll', checkIfVisibleDebounced);
return () => window.removeEventListener('scroll', checkIfVisibleDebounced);
}, [checkIfVisibleDebounced]);
const onImageLoad = useCallback(() => { const onImageLoad = useCallback(() => {
setIsLoaded(true); setIsLoaded(true);
}, [setIsLoaded]); }, [setIsLoaded]);
// Replaced it with <Link>, maybe, you can remove it completely with NodeSelect action // 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 has_description = description && description.length > 32;
const text = const text =
@ -119,6 +119,8 @@ const Cell: FC<IProps> = ({
} }
}, [title]); }, [title]);
const cellText = useMemo(() => formatCellText(text || ''), [text]);
return ( return (
<div className={classNames(styles.cell, styles[(flow && flow.display) || 'single'])} ref={ref}> <div className={classNames(styles.cell, styles[(flow && flow.display) || 'single'])} ref={ref}>
{is_visible && ( {is_visible && (
@ -150,7 +152,10 @@ const Cell: FC<IProps> = ({
<div className={styles.text}> <div className={styles.text}>
{title && <div className={styles.text_title}>{title}</div>} {title && <div className={styles.text_title}>{title}</div>}
<Group dangerouslySetInnerHTML={{ __html: formatCellText(text) }} /> <div
dangerouslySetInnerHTML={{ __html: cellText }}
className={markdown.wrapper}
/>
</div> </div>
)} )}
@ -158,7 +163,10 @@ const Cell: FC<IProps> = ({
<div className={styles.text_only}> <div className={styles.text_only}>
{title && <div className={styles.text_title}>{title}</div>} {title && <div className={styles.text_title}>{title}</div>}
<Group dangerouslySetInnerHTML={{ __html: formatCellText(text) }} /> <div
dangerouslySetInnerHTML={{ __html: cellText }}
className={markdown.wrapper}
/>
</div> </div>
)} )}
</div> </div>

View file

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

View file

@ -4,19 +4,11 @@ import { describeArc } from '~/utils/dom';
interface IProps { interface IProps {
size: number; 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}> <svg className={styles.icon} width={size} height={size}>
<path <path d={describeArc(size / 2, size / 2, size / 2 - 2, 360 * (1 - progress), 360)} />
d={describeArc(
size / 2,
size / 2,
size / 2 - 2,
360 * (1 - progress),
360,
)}
/>
</svg> </svg>
); );

View file

@ -1,8 +1,16 @@
import classnames from 'classnames'; 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 styles from './styles.module.scss';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { IIcon } from '~/redux/types'; import { IIcon } from '~/redux/types';
import { usePopper } from 'react-popper';
type IButtonProps = DetailedHTMLProps< type IButtonProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>, ButtonHTMLAttributes<HTMLButtonElement>,
@ -19,6 +27,7 @@ type IButtonProps = DetailedHTMLProps<
is_loading?: boolean; is_loading?: boolean;
stretchy?: boolean; stretchy?: boolean;
iconOnly?: boolean; iconOnly?: boolean;
label?: string;
}; };
const Button: FC<IButtonProps> = memo( const Button: FC<IButtonProps> = memo(
@ -37,9 +46,24 @@ const Button: FC<IButtonProps> = memo(
stretchy, stretchy,
disabled, disabled,
iconOnly, iconOnly,
label,
ref,
...props ...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', seamless || non_submitting ? 'div' : 'button',
{ {
className: classnames(styles.button, className, styles[size], styles[color], { 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} />, iconLeft && <Icon icon={iconLeft} size={20} key={0} className={styles.icon_left} />,
title ? <span>{title}</span> : children || null, title ? <span>{title}</span> : children || null,
iconRight && <Icon icon={iconRight} size={20} key={2} className={styles.icon_right} />, 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 }; export { Button };

View file

@ -10,6 +10,7 @@
} }
.button { .button {
position: relative;
height: $input_height; height: $input_height;
border: none; border: none;
box-sizing: border-box; box-sizing: border-box;
@ -177,17 +178,6 @@
fill: white; fill: white;
} }
} }
> * {
margin: 0 5px;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
} }
.micro { .micro {
@ -235,3 +225,21 @@
width: 20px; width: 20px;
height: 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 React, { HTMLAttributes } from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import classNames from 'classnames';
type IProps = HTMLAttributes<HTMLDivElement> & {}; 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 classNames from 'classnames';
import styles from '~/styles/common/inputs.module.scss'; import styles from '~/styles/common/inputs.module.scss';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { IInputTextProps } from '~/redux/types'; import { IInputTextProps } from '~/redux/types';
import { LoaderCircle } from '~/components/input/LoaderCircle'; import { LoaderCircle } from '~/components/input/LoaderCircle';
import { useTranslatedError } from '~/utils/hooks/useTranslatedError';
const InputText: FC<IInputTextProps> = ({ const InputText: FC<IInputTextProps> = ({
wrapperClassName, wrapperClassName,
@ -20,16 +21,24 @@ const InputText: FC<IInputTextProps> = ({
...props ...props
}) => { }) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [inner_ref, setInnerRef] = useState<HTMLInputElement>(null); const [inner_ref, setInnerRef] = useState<HTMLInputElement | null>(null);
const onInput = useCallback( const onInput = useCallback(
({ target }: ChangeEvent<HTMLInputElement>) => handler(target.value), ({ target }: ChangeEvent<HTMLInputElement>) => {
if (!handler) {
return;
}
handler(target.value);
},
[handler] [handler]
); );
const onFocus = useCallback(() => setFocused(true), []); const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(() => setFocused(false), []); const onBlur = useCallback(() => setFocused(false), []);
const translatedError = useTranslatedError(error);
useEffect(() => { useEffect(() => {
if (onRef) onRef(inner_ref); if (onRef) onRef(inner_ref);
}, [inner_ref, onRef]); }, [inner_ref, onRef]);
@ -80,9 +89,9 @@ const InputText: FC<IInputTextProps> = ({
</div> </div>
)} )}
{error && ( {!!translatedError && (
<div className={styles.error}> <div className={styles.error}>
<span>{error}</span> <span>{translatedError}</span>
</div> </div>
)} )}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +1,23 @@
import React, { FC, useCallback, KeyboardEventHandler, useEffect, useMemo } from 'react'; import React, { FC } from 'react';
import { Textarea } from '~/components/input/Textarea';
import { CommentWrapper } from '~/components/containers/CommentWrapper'; 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 { connect } from 'react-redux';
import * as NODE_ACTIONS from '~/redux/node/actions'; import { selectAuthUser } from '~/redux/auth/selectors';
import { selectNode } from '~/redux/node/selectors'; import { CommentForm } from '~/components/comment/CommentForm';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; import { INode } from '~/redux/types';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { selectUser, selectAuthUser } from '~/redux/auth/selectors';
import { CommentForm } from '../../comment/CommentForm';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
user: selectAuthUser(state), user: selectAuthUser(state),
}); });
type IProps = ReturnType<typeof mapStateToProps> & { 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 ( return (
<CommentWrapper user={user}> <CommentWrapper user={user}>
<CommentForm id={0} is_before={is_before} /> <CommentForm nodeId={nodeId} />
</CommentWrapper> </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 { Comment } from '../../comment/Comment';
import { Filler } from '~/components/containers/Filler';
import styles from './styles.module.scss'; 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 { groupCommentsByUser } from '~/utils/fn';
import { IUser } from '~/redux/auth/types'; import { IUser } from '~/redux/auth/types';
import { canEditComment } from '~/utils/node'; 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 { INodeState } from '~/redux/node/reducer';
import { COMMENTS_DISPLAY } from '~/redux/node/constants'; import { COMMENTS_DISPLAY } from '~/redux/node/constants';
import { plural } from '~/utils/dom'; 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 { interface IProps {
comments?: IComment[]; comments: IComment[];
comment_data: INodeState['comment_data']; count: INodeState['comment_count'];
comment_count: INodeState['comment_count'];
user: IUser; user: IUser;
onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
onLoadMore: typeof nodeLoadMoreComments;
order?: 'ASC' | 'DESC'; order?: 'ASC' | 'DESC';
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
} }
const NodeComments: FC<IProps> = memo( const NodeComments: FC<IProps> = memo(({ comments, user, count = 0, order = 'DESC' }) => {
({ const dispatch = useDispatch();
comments, const left = useMemo(() => Math.max(0, count - comments.length), [comments, count]);
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 groupped: ICommentGroup[] = useMemo( const groupped: ICommentGroup[] = useMemo(
() => (order === 'DESC' ? [...comments].reverse() : comments).reduce(groupCommentsByUser, []), () => (order === 'DESC' ? [...comments].reverse() : comments).reduce(groupCommentsByUser, []),
[comments, order] [comments, order]
); );
const more = useMemo( const onDelete = useCallback(
() => (id: IComment['id'], locked: boolean) => dispatch(nodeLockComment(id, locked)),
comments_left > 0 && ( [dispatch]
<div className={styles.more} onClick={onLoadMore}> );
Показать ещё{' '} const onLoadMoreComments = useCallback(() => dispatch(nodeLoadMoreComments()), [dispatch]);
{plural( const onShowPhotoswipe = useCallback(
Math.min(comments_left, COMMENTS_DISPLAY), (images: IFile[], index: number) => dispatch(modalShowPhotoswipe(images, index)),
'комментарий', [dispatch]
'комментария', );
'комментариев'
)}
{comments_left > COMMENTS_DISPLAY ? ` из ${comments_left} оставшихся` : ''}
</div>
),
[comments_left, onLoadMore, COMMENTS_DISPLAY]
);
return ( const more = useMemo(
<div className={styles.wrap}> () =>
{order === 'DESC' && more} left > 0 && (
<div className={styles.more} onClick={onLoadMoreComments}>
Показать ещё{' '}
{plural(Math.min(left, COMMENTS_DISPLAY), 'комментарий', 'комментария', 'комментариев')}
{left > COMMENTS_DISPLAY ? ` из ${left} оставшихся` : ''}
</div>
),
[left, onLoadMoreComments]
);
{groupped.map(group => ( return (
<Comment <div className={styles.wrap}>
key={group.ids.join()} {order === 'DESC' && more}
comment_group={group}
comment_data={comment_data}
can_edit={canEditComment(group, user)}
onDelete={onDelete}
onEdit={onEdit}
modalShowPhotoswipe={modalShowPhotoswipe}
/>
))}
{order === 'ASC' && more} {groupped.map(group => (
</div> <Comment
); key={group.ids.join()}
} comment_group={group}
); can_edit={canEditComment(group, user)}
onDelete={onDelete}
modalShowPhotoswipe={onShowPhotoswipe}
/>
))}
{order === 'ASC' && more}
</div>
);
});
export { NodeComments }; 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 styles from './styles.module.scss';
import classNames from 'classnames'; import classNames from 'classnames';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { INodeComponentProps } from '~/redux/node/constants'; import { INodeComponentProps } from '~/redux/node/constants';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { throttle } from 'throttle-debounce'; import { throttle } from 'throttle-debounce';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { useArrows } from '~/utils/hooks/keys'; import { useArrows } from '~/utils/hooks/keys';
@ -37,8 +36,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
const [is_dragging, setIsDragging] = useState(false); const [is_dragging, setIsDragging] = useState(false);
const [drag_start, setDragStart] = useState(0); const [drag_start, setDragStart] = useState(0);
const slide = useRef<HTMLDivElement>(); const slide = useRef<HTMLDivElement>(null);
const wrap = useRef<HTMLDivElement>(); const wrap = useRef<HTMLDivElement>(null);
const setHeightThrottled = useCallback(throttle(100, setHeight), [setHeight]); const setHeightThrottled = useCallback(throttle(100, setHeight), [setHeight]);
@ -222,6 +221,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
const changeCurrent = useCallback( const changeCurrent = useCallback(
(item: number) => { (item: number) => {
if (!wrap.current) return;
const { width } = wrap.current.getBoundingClientRect(); const { width } = wrap.current.getBoundingClientRect();
setOffset(-1 * item * width); setOffset(-1 * item * width);
}, },
@ -267,10 +268,10 @@ const NodeImageSlideBlock: FC<IProps> = ({
[styles.is_active]: index === current, [styles.is_active]: index === current,
})} })}
ref={setRef(index)} ref={setRef(index)}
key={node.updated_at + file.id} key={`${node?.updated_at || ''} + ${file?.id || ''} + ${index}`}
> >
<svg <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] })} className={classNames(styles.preview, { [styles.is_loaded]: loaded[index] })}
style={{ style={{
maxHeight: max_height, maxHeight: max_height,
@ -279,19 +280,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
> >
<defs> <defs>
<filter id="f1" x="0" y="0"> <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 <feGaussianBlur
stdDeviation="15 15" stdDeviation="5 5"
x="0%" x="0%"
y="0%" y="0%"
width="100%" 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 }) => { ({ node, layout, can_edit, can_like, can_star, is_loading, onEdit, onLike, onStar, onLock }) => {
const [stack, setStack] = useState(false); const [stack, setStack] = useState(false);
const ref = useRef(null); const ref = useRef<HTMLDivElement>(null);
const getPlace = useCallback(() => { const getPlace = useCallback(() => {
if (!ref.current) return; if (!ref.current) return;
const { bottom } = ref.current.getBoundingClientRect(); const { bottom } = ref.current!.getBoundingClientRect();
setStack(bottom > window.innerHeight); setStack(bottom > window.innerHeight);
}, [ref]); }, [ref]);
@ -75,7 +75,7 @@ const NodePanel: FC<IProps> = memo(
can_edit={can_edit} can_edit={can_edit}
can_like={can_like} can_like={can_like}
can_star={can_star} can_star={can_star}
is_loading={is_loading} is_loading={!!is_loading}
/> />
</div> </div>
); );

View file

@ -96,7 +96,9 @@ const NodePanelInner: FC<IProps> = memo(
<Icon icon="heart" size={24} onClick={onLike} /> <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>
)} )}
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,9 +31,9 @@ export const API = {
RELATED: (id: INode['id']) => `/node/${id}/related`, RELATED: (id: INode['id']) => `/node/${id}/related`,
UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`, UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`,
POST_LIKE: (id: INode['id']) => `/node/${id}/like`, POST_LIKE: (id: INode['id']) => `/node/${id}/like`,
POST_STAR: (id: INode['id']) => `/node/${id}/heroic`, POST_HEROIC: (id: INode['id']) => `/node/${id}/heroic`,
POST_LOCK: (id: INode['id']) => `/node/${id}/lock`, POST_LOCK: (id: INode['id']) => `/node/${id}/lock`,
POST_LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) => LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) =>
`/node/${id}/comment/${comment_id}/lock`, `/node/${id}/comment/${comment_id}/lock`,
SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`, SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`,
}, },

View file

@ -42,6 +42,7 @@ export const ERRORS = {
CANT_RESTORE_COMMENT: 'CantRestoreComment', CANT_RESTORE_COMMENT: 'CantRestoreComment',
MESSAGE_NOT_FOUND: 'MessageNotFound', MESSAGE_NOT_FOUND: 'MessageNotFound',
COMMENT_TOO_LONG: 'CommentTooLong', COMMENT_TOO_LONG: 'CommentTooLong',
NETWORK_ERROR: 'Network Error',
}; };
export const ERROR_LITERAL = { export const ERROR_LITERAL = {
@ -89,4 +90,5 @@ export const ERROR_LITERAL = {
[ERRORS.CANT_RESTORE_COMMENT]: 'Не удалось восстановить комментарий', [ERRORS.CANT_RESTORE_COMMENT]: 'Не удалось восстановить комментарий',
[ERRORS.MESSAGE_NOT_FOUND]: 'Сообщение не найдено', [ERRORS.MESSAGE_NOT_FOUND]: 'Сообщение не найдено',
[ERRORS.COMMENT_TOO_LONG]: 'Комментарий слишком длинный', [ERRORS.COMMENT_TOO_LONG]: 'Комментарий слишком длинный',
[ERRORS.NETWORK_ERROR]: 'Подключение не удалось',
}; };

View file

@ -1,3 +1,5 @@
import { INode } from '~/redux/types';
export const URLS = { export const URLS = {
BASE: '/', BASE: '/',
BORIS: '/boris', BORIS: '/boris',
@ -12,7 +14,7 @@ export const URLS = {
NOT_FOUND: '/lost', NOT_FOUND: '/lost',
BACKEND_DOWN: '/oopsie', BACKEND_DOWN: '/oopsie',
}, },
NODE_URL: (id: number | string) => `/post${id}`, NODE_URL: (id: INode['id'] | string) => `/post${id}`,
NODE_TAG_URL: (id: number, tagName: string) => `/post${id}/tag/${tagName}`, NODE_TAG_URL: (id: number, tagName: string) => `/post${id}/tag/${tagName}`,
PROFILE: (username: string) => `/~${username}`, PROFILE: (username: string) => `/~${username}`,
PROFILE_PAGE: (username: string) => `/profile/${username}`, PROFILE_PAGE: (username: string) => `/profile/${username}`,

View file

@ -1,9 +1,9 @@
import React, { FC, MouseEventHandler, ReactElement, useEffect, useRef } from "react"; import React, { FC, MouseEventHandler, ReactElement, useEffect, useRef } from 'react';
import styles from "./styles.module.scss"; import styles from './styles.module.scss';
import { clearAllBodyScrollLocks, disableBodyScroll } from "body-scroll-lock"; import { clearAllBodyScrollLocks, disableBodyScroll } from 'body-scroll-lock';
import { Icon } from "~/components/input/Icon"; import { Icon } from '~/components/input/Icon';
import { LoaderCircle } from "~/components/input/LoaderCircle"; import { LoaderCircle } from '~/components/input/LoaderCircle';
import { useCloseOnEscape } from "~/utils/hooks"; import { useCloseOnEscape } from '~/utils/hooks';
interface IProps { interface IProps {
children: React.ReactChild; children: React.ReactChild;
@ -14,7 +14,7 @@ interface IProps {
width?: number; width?: number;
error?: string; error?: string;
is_loading?: boolean; is_loading?: boolean;
overlay?: ReactElement; overlay?: JSX.Element;
onOverlayClick?: MouseEventHandler<HTMLDivElement>; onOverlayClick?: MouseEventHandler<HTMLDivElement>;
onRefCapture?: (ref: any) => void; onRefCapture?: (ref: any) => void;

View file

@ -1,4 +1,12 @@
import React, { createElement, FC, FormEvent, useCallback, useEffect, useState } from 'react'; import React, {
createElement,
FC,
FormEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { IDialogProps } from '~/redux/modal/constants'; import { IDialogProps } from '~/redux/modal/constants';
import { useCloseOnEscape } from '~/utils/hooks'; import { useCloseOnEscape } from '~/utils/hooks';
@ -16,6 +24,7 @@ import { EMPTY_NODE, NODE_EDITORS } from '~/redux/node/constants';
import { BetterScrollDialog } from '../BetterScrollDialog'; import { BetterScrollDialog } from '../BetterScrollDialog';
import { CoverBackdrop } from '~/components/containers/CoverBackdrop'; import { CoverBackdrop } from '~/components/containers/CoverBackdrop';
import { IEditorComponentProps } from '~/redux/node/types'; import { IEditorComponentProps } from '~/redux/node/types';
import { has, values } from 'ramda';
const mapStateToProps = state => { const mapStateToProps = state => {
const { editor, errors } = selectNode(state); const { editor, errors } = selectNode(state);
@ -32,7 +41,7 @@ const mapDispatchToProps = {
type IProps = IDialogProps & type IProps = IDialogProps &
ReturnType<typeof mapStateToProps> & ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & { typeof mapDispatchToProps & {
type: keyof typeof NODE_EDITORS; type: string;
}; };
const EditorDialogUnconnected: FC<IProps> = ({ const EditorDialogUnconnected: FC<IProps> = ({
@ -44,7 +53,7 @@ const EditorDialogUnconnected: FC<IProps> = ({
type, type,
}) => { }) => {
const [data, setData] = useState(EMPTY_NODE); const [data, setData] = useState(EMPTY_NODE);
const [temp, setTemp] = useState([]); const [temp, setTemp] = useState<string[]>([]);
useEffect(() => setData(editor), [editor]); useEffect(() => setData(editor), [editor]);
@ -78,7 +87,13 @@ const EditorDialogUnconnected: FC<IProps> = ({
<EditorPanel data={data} setData={setData} temp={temp} setTemp={setTemp} /> <EditorPanel data={data} setData={setData} temp={temp} setTemp={setTemp} />
<Group horizontal> <Group horizontal>
<InputText title="Название" value={data.title} handler={setTitle} autoFocus /> <InputText
title="Название"
value={data.title}
handler={setTitle}
autoFocus
maxLength={256}
/>
<Button title="Сохранить" iconRight="check" /> <Button title="Сохранить" iconRight="check" />
</Group> </Group>
@ -87,9 +102,18 @@ const EditorDialogUnconnected: FC<IProps> = ({
useCloseOnEscape(onRequestClose); useCloseOnEscape(onRequestClose);
const error = errors && Object.values(errors)[0]; const error = values(errors)[0];
const component = useMemo(() => {
if (!has(type, NODE_EDITORS)) {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(NODE_EDITORS, type)) return null; return NODE_EDITORS[type];
}, [type]);
if (!component) {
return null;
}
return ( return (
<form onSubmit={onSubmit} className={styles.form}> <form onSubmit={onSubmit} className={styles.form}>
@ -101,7 +125,7 @@ const EditorDialogUnconnected: FC<IProps> = ({
onClose={onRequestClose} onClose={onRequestClose}
> >
<div className={styles.editor}> <div className={styles.editor}>
{createElement(NODE_EDITORS[type], { {createElement(component, {
data, data,
setData, setData,
temp, temp,

View file

@ -1,4 +1,4 @@
import React, { FC, FormEvent, useCallback, useEffect, useState } from 'react'; import React, { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { DIALOGS, IDialogProps } from '~/redux/modal/constants'; import { DIALOGS, IDialogProps } from '~/redux/modal/constants';
import { useCloseOnEscape } from '~/utils/hooks'; import { useCloseOnEscape } from '~/utils/hooks';
@ -18,6 +18,8 @@ import { pick } from 'ramda';
import { LoginDialogButtons } from '~/containers/dialogs/LoginDialogButtons'; import { LoginDialogButtons } from '~/containers/dialogs/LoginDialogButtons';
import { OAUTH_EVENT_TYPES } from '~/redux/types'; import { OAUTH_EVENT_TYPES } from '~/redux/types';
import { DialogTitle } from '~/components/dialogs/DialogTitle'; import { DialogTitle } from '~/components/dialogs/DialogTitle';
import { ERROR_LITERAL } from '~/constants/errors';
import { useTranslatedError } from '~/utils/hooks/useTranslatedError';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
...pick(['error', 'is_registering'], selectAuthLogin(state)), ...pick(['error', 'is_registering'], selectAuthLogin(state)),
@ -63,7 +65,6 @@ const LoginDialogUnconnected: FC<IProps> = ({
const openOauthWindow = useCallback( const openOauthWindow = useCallback(
(provider: ISocialProvider) => () => { (provider: ISocialProvider) => () => {
console.log(API.USER.OAUTH_WINDOW(provider));
window.open(API.USER.OAUTH_WINDOW(provider), '', 'width=600,height=400'); window.open(API.USER.OAUTH_WINDOW(provider), '', 'width=600,height=400');
}, },
[] []
@ -81,7 +82,7 @@ const LoginDialogUnconnected: FC<IProps> = ({
); );
useEffect(() => { useEffect(() => {
if (error) userSetLoginError(null); if (error) userSetLoginError('');
}, [username, password]); }, [username, password]);
useEffect(() => { useEffect(() => {
@ -91,14 +92,17 @@ const LoginDialogUnconnected: FC<IProps> = ({
useCloseOnEscape(onRequestClose); useCloseOnEscape(onRequestClose);
const translatedError = useTranslatedError(error);
return ( return (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div> <div>
<BetterScrollDialog <BetterScrollDialog
width={300} width={300}
error={error} error={translatedError}
onClose={onRequestClose} onClose={onRequestClose}
footer={<LoginDialogButtons openOauthWindow={openOauthWindow} />} footer={<LoginDialogButtons openOauthWindow={openOauthWindow} />}
backdrop={<div className={styles.backdrop} />}
> >
<Padder> <Padder>
<div className={styles.wrap}> <div className={styles.wrap}>

View file

@ -45,3 +45,11 @@ $vk_color: $secondary_color;
color: lighten($content_bg, 40%); color: lighten($content_bg, 40%);
} }
} }
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}

View file

@ -3,9 +3,10 @@ import { Button } from '~/components/input/Button';
import { Grid } from '~/components/containers/Grid'; import { Grid } from '~/components/containers/Grid';
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 { ISocialProvider } from '~/redux/auth/types';
interface IProps { interface IProps {
openOauthWindow: (provider: string) => MouseEventHandler; openOauthWindow: (provider: ISocialProvider) => MouseEventHandler;
} }
const LoginDialogButtons: FC<IProps> = ({ openOauthWindow }) => ( const LoginDialogButtons: FC<IProps> = ({ openOauthWindow }) => (

View file

@ -24,7 +24,7 @@ const ModalUnconnected: FC<IProps> = ({
}) => { }) => {
const onRequestClose = useCallback(() => { const onRequestClose = useCallback(() => {
modalSetShown(false); modalSetShown(false);
modalSetDialog(null); modalSetDialog('');
}, [modalSetShown, modalSetDialog]); }, [modalSetShown, modalSetDialog]);
if (!dialog || !DIALOG_CONTENT[dialog] || !is_shown) return null; if (!dialog || !DIALOG_CONTENT[dialog] || !is_shown) return null;
@ -43,10 +43,7 @@ const ModalUnconnected: FC<IProps> = ({
); );
}; };
const Modal = connect( const Modal = connect(mapStateToProps, mapDispatchToProps)(ModalUnconnected);
mapStateToProps,
mapDispatchToProps
)(ModalUnconnected);
export { ModalUnconnected, Modal }; export { ModalUnconnected, Modal };

View file

@ -78,7 +78,9 @@ const PhotoSwipeUnconnected: FC<Props> = ({ photoswipe, modalSetShown }) => {
useEffect(() => { useEffect(() => {
window.location.hash = 'preview'; window.location.hash = 'preview';
return () => (window.location.hash = ''); return () => {
window.location.hash = '';
};
}, []); }, []);
return ( return (

View file

@ -1,4 +1,4 @@
import React, { FC, useState, useMemo, useCallback, useEffect } from 'react'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { IDialogProps } from '~/redux/types'; import { IDialogProps } from '~/redux/types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { BetterScrollDialog } from '../BetterScrollDialog'; import { BetterScrollDialog } from '../BetterScrollDialog';
@ -49,7 +49,7 @@ const RestorePasswordDialogUnconnected: FC<IProps> = ({
useEffect(() => { useEffect(() => {
if (error || is_succesfull) { if (error || is_succesfull) {
authSetRestore({ error: null, is_succesfull: false }); authSetRestore({ error: '', is_succesfull: false });
} }
}, [password, password_again]); }, [password, password_again]);
@ -69,7 +69,7 @@ const RestorePasswordDialogUnconnected: FC<IProps> = ({
<Icon icon="check" size={64} /> <Icon icon="check" size={64} />
<div>Пароль обновлен</div> <div>Пароль обновлен</div>
<div>Добро пожаловать домой, ~{user.username}!</div> <div>Добро пожаловать домой, ~{user?.username}!</div>
<div /> <div />
@ -77,14 +77,16 @@ const RestorePasswordDialogUnconnected: FC<IProps> = ({
Ура! Ура!
</Button> </Button>
</Group> </Group>
) : null, ) : (
undefined
),
[is_succesfull] [is_succesfull]
); );
const not_ready = useMemo(() => (is_loading && !user ? <div className={styles.shade} /> : null), [ const not_ready = useMemo(
is_loading, () => (is_loading && !user ? <div className={styles.shade} /> : undefined),
user, [is_loading, user]
]); );
const invalid_code = useMemo( const invalid_code = useMemo(
() => () =>
@ -100,7 +102,9 @@ const RestorePasswordDialogUnconnected: FC<IProps> = ({
Очень жаль Очень жаль
</Button> </Button>
</Group> </Group>
) : null, ) : (
undefined
),
[is_loading, user, error] [is_loading, user, error]
); );
@ -135,7 +139,7 @@ const RestorePasswordDialogUnconnected: FC<IProps> = ({
type="password" type="password"
value={password_again} value={password_again}
handler={setPasswordAgain} handler={setPasswordAgain}
error={password_again && doesnt_match && ERROR_LITERAL[ERRORS.DOESNT_MATCH]} error={password_again && doesnt_match ? ERROR_LITERAL[ERRORS.DOESNT_MATCH] : ''}
/> />
<Group className={styles.text}> <Group className={styles.text}>

View file

@ -43,7 +43,7 @@ const RestoreRequestDialogUnconnected: FC<IProps> = ({
useEffect(() => { useEffect(() => {
if (error || is_succesfull) { if (error || is_succesfull) {
authSetRestore({ error: null, is_succesfull: false }); authSetRestore({ error: '', is_succesfull: false });
} }
}, [field]); }, [field]);
@ -72,7 +72,9 @@ const RestoreRequestDialogUnconnected: FC<IProps> = ({
Отлично! Отлично!
</Button> </Button>
</Group> </Group>
) : null, ) : (
undefined
),
[is_succesfull] [is_succesfull]
); );

View file

@ -1,8 +1,7 @@
import React, { FC, useEffect } from 'react'; import React, { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router'; import { selectNode, selectNodeComments } from '~/redux/node/selectors';
import { selectNode } from '~/redux/node/selectors';
import { selectUser } from '~/redux/auth/selectors'; import { selectUser } from '~/redux/auth/selectors';
import { connect } from 'react-redux'; import { useDispatch } from 'react-redux';
import { NodeComments } from '~/components/node/NodeComments'; import { NodeComments } from '~/components/node/NodeComments';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
@ -10,72 +9,50 @@ import boris from '~/sprites/boris_robot.svg';
import { NodeNoComments } from '~/components/node/NodeNoComments'; import { NodeNoComments } from '~/components/node/NodeNoComments';
import { useRandomPhrase } from '~/constants/phrases'; import { useRandomPhrase } from '~/constants/phrases';
import { NodeCommentForm } from '~/components/node/NodeCommentForm'; import { NodeCommentForm } from '~/components/node/NodeCommentForm';
import * as NODE_ACTIONS from '~/redux/node/actions';
import * as AUTH_ACTIONS from '~/redux/auth/actions';
import * as MODAL_ACTIONS from '~/redux/modal/actions';
import * as BORIS_ACTIONS from '~/redux/boris/actions';
import isBefore from 'date-fns/isBefore'; import isBefore from 'date-fns/isBefore';
import { Card } from '~/components/containers/Card'; import { Card } from '~/components/containers/Card';
import { Footer } from '~/components/main/Footer'; import { Footer } from '~/components/main/Footer';
import { Sticky } from '~/components/containers/Sticky'; import { Sticky } from '~/components/containers/Sticky';
import { selectBorisStats } from '~/redux/boris/selectors';
import { BorisStats } from '~/components/boris/BorisStats'; import { BorisStats } from '~/components/boris/BorisStats';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectBorisStats } from '~/redux/boris/selectors';
import { authSetUser } from '~/redux/auth/actions';
import { nodeLoadNode } from '~/redux/node/actions';
import { borisLoadStats } from '~/redux/boris/actions';
const mapStateToProps = state => ({ type IProps = {};
node: selectNode(state),
user: selectUser(state),
stats: selectBorisStats(state),
});
const mapDispatchToProps = { const BorisLayout: FC<IProps> = () => {
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
nodeLockComment: NODE_ACTIONS.nodeLockComment,
nodeEditComment: NODE_ACTIONS.nodeEditComment,
nodeLoadMoreComments: NODE_ACTIONS.nodeLoadMoreComments,
authSetUser: AUTH_ACTIONS.authSetUser,
modalShowPhotoswipe: MODAL_ACTIONS.modalShowPhotoswipe,
borisLoadStats: BORIS_ACTIONS.borisLoadStats,
};
type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps &
RouteComponentProps<{ id: string }> & {};
const id = 696;
const BorisLayoutUnconnected: FC<IProps> = ({
node: { is_loading, is_loading_comments, comments = [], comment_data, comment_count },
user,
user: { is_user, last_seen_boris },
nodeLoadNode,
nodeLockComment,
nodeEditComment,
nodeLoadMoreComments,
modalShowPhotoswipe,
authSetUser,
borisLoadStats,
stats,
}) => {
const title = useRandomPhrase('BORIS_TITLE'); const title = useRandomPhrase('BORIS_TITLE');
const dispatch = useDispatch();
const node = useShallowSelect(selectNode);
const user = useShallowSelect(selectUser);
const stats = useShallowSelect(selectBorisStats);
const comments = useShallowSelect(selectNodeComments);
useEffect(() => { useEffect(() => {
const last_comment = comments[0]; const last_comment = comments[0];
if (!last_comment) return; if (!last_comment) return;
if (last_seen_boris && !isBefore(new Date(last_seen_boris), new Date(last_comment.created_at)))
if (
user.last_seen_boris &&
last_comment.created_at &&
!isBefore(new Date(user.last_seen_boris), new Date(last_comment.created_at))
)
return; return;
authSetUser({ last_seen_boris: last_comment.created_at }); dispatch(authSetUser({ last_seen_boris: last_comment.created_at }));
}, [comments, last_seen_boris]); }, [user.last_seen_boris, dispatch, comments]);
useEffect(() => { useEffect(() => {
if (is_loading) return; if (node.is_loading) return;
nodeLoadNode(id, 'DESC'); dispatch(nodeLoadNode(696, 'DESC'));
}, [nodeLoadNode, id]); }, [dispatch]);
useEffect(() => { useEffect(() => {
borisLoadStats(); dispatch(borisLoadStats());
}, [borisLoadStats]); }, [dispatch]);
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>
@ -92,20 +69,15 @@ const BorisLayoutUnconnected: FC<IProps> = ({
<div className={styles.container}> <div className={styles.container}>
<Card className={styles.content}> <Card className={styles.content}>
<Group className={styles.grid}> <Group className={styles.grid}>
{is_user && <NodeCommentForm is_before />} {user.is_user && <NodeCommentForm isBefore nodeId={node.current.id} />}
{is_loading_comments ? ( {node.is_loading_comments ? (
<NodeNoComments is_loading count={7} /> <NodeNoComments is_loading count={7} />
) : ( ) : (
<NodeComments <NodeComments
comments={comments} comments={comments}
comment_data={comment_data} count={node.comment_count}
comment_count={comment_count}
user={user} user={user}
onDelete={nodeLockComment}
onEdit={nodeEditComment}
onLoadMore={nodeLoadMoreComments}
modalShowPhotoswipe={modalShowPhotoswipe}
order="ASC" order="ASC"
/> />
)} )}
@ -139,6 +111,4 @@ const BorisLayoutUnconnected: FC<IProps> = ({
); );
}; };
const BorisLayout = connect(mapStateToProps, mapDispatchToProps)(BorisLayoutUnconnected);
export { BorisLayout }; export { BorisLayout };

View file

@ -12,9 +12,14 @@ import { NodeNoComments } from '~/components/node/NodeNoComments';
import { NodeRelated } from '~/components/node/NodeRelated'; import { NodeRelated } from '~/components/node/NodeRelated';
import { NodeComments } from '~/components/node/NodeComments'; import { NodeComments } from '~/components/node/NodeComments';
import { NodeTags } from '~/components/node/NodeTags'; import { NodeTags } from '~/components/node/NodeTags';
import { INodeComponentProps, NODE_COMPONENTS, NODE_HEADS, NODE_INLINES, } from '~/redux/node/constants'; import {
INodeComponentProps,
NODE_COMPONENTS,
NODE_HEADS,
NODE_INLINES,
} from '~/redux/node/constants';
import { selectUser } from '~/redux/auth/selectors'; import { selectUser } from '~/redux/auth/selectors';
import { pick } from 'ramda'; import { path, pick, prop } from 'ramda';
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder'; import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
import { NodeCommentForm } from '~/components/node/NodeCommentForm'; import { NodeCommentForm } from '~/components/node/NodeCommentForm';
@ -30,6 +35,7 @@ import { selectModal } from '~/redux/modal/selectors';
import { SidebarRouter } from '~/containers/main/SidebarRouter'; import { SidebarRouter } from '~/containers/main/SidebarRouter';
import { ITag } from '~/redux/types'; import { ITag } from '~/redux/types';
import { URLS } from '~/constants/urls'; import { URLS } from '~/constants/urls';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
const mapStateToProps = (state: IState) => ({ const mapStateToProps = (state: IState) => ({
node: selectNode(state), node: selectNode(state),
@ -60,15 +66,6 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
match: { match: {
params: { id }, params: { id },
}, },
node: {
is_loading,
is_loading_comments,
comments = [],
current: node,
related,
comment_data,
comment_count,
},
modal: { is_shown: is_modal_shown }, modal: { is_shown: is_modal_shown },
user, user,
user: { is_user }, user: { is_user },
@ -79,14 +76,18 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
nodeStar, nodeStar,
nodeLock, nodeLock,
nodeSetCoverImage, nodeSetCoverImage,
nodeLockComment,
nodeEditComment,
nodeLoadMoreComments,
modalShowPhotoswipe, modalShowPhotoswipe,
}) => { }) => {
const [layout, setLayout] = useState({}); const [layout, setLayout] = useState({});
const history = useHistory(); const history = useHistory();
const {
is_loading,
is_loading_comments,
comments = [],
current: node,
related,
comment_count,
} = useShallowSelect(selectNode);
const updateLayout = useCallback(() => setLayout({}), []); const updateLayout = useCallback(() => setLayout({}), []);
useEffect(() => { useEffect(() => {
@ -103,6 +104,10 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
const onTagClick = useCallback( const onTagClick = useCallback(
(tag: Partial<ITag>) => { (tag: Partial<ITag>) => {
if (!node?.id || !tag?.title) {
return;
}
history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title))); history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title)));
}, },
[history, node.id] [history, node.id]
@ -112,9 +117,9 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
const can_like = useMemo(() => canLikeNode(node, user), [node, user]); const can_like = useMemo(() => canLikeNode(node, user), [node, user]);
const can_star = useMemo(() => canStarNode(node, user), [node, user]); const can_star = useMemo(() => canStarNode(node, user), [node, user]);
const head = node && node.type && NODE_HEADS[node.type]; const head = useMemo(() => node?.type && prop(node?.type, NODE_HEADS), [node.type]);
const block = node && node.type && NODE_COMPONENTS[node.type]; const block = useMemo(() => node?.type && prop(node?.type, NODE_COMPONENTS), [node.type]);
const inline = node && node.type && NODE_INLINES[node.type]; const inline = useMemo(() => node?.type && prop(node?.type, NODE_INLINES), [node.type]);
const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]); const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]);
const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]); const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]);
@ -147,10 +152,10 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
return ( return (
<> <>
{createNodeBlock(head)} {!!head && createNodeBlock(head)}
<Card className={styles.node} seamless> <Card className={styles.node} seamless>
{createNodeBlock(block)} {!!block && createNodeBlock(block)}
<NodePanel <NodePanel
node={pick( node={pick(
@ -181,19 +186,14 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
<NodeNoComments is_loading={is_loading_comments || is_loading} /> <NodeNoComments is_loading={is_loading_comments || is_loading} />
) : ( ) : (
<NodeComments <NodeComments
count={comment_count}
comments={comments} comments={comments}
comment_data={comment_data}
comment_count={comment_count}
user={user} user={user}
onDelete={nodeLockComment}
onEdit={nodeEditComment}
onLoadMore={nodeLoadMoreComments}
modalShowPhotoswipe={modalShowPhotoswipe}
order="DESC" order="DESC"
/> />
)} )}
{is_user && !is_loading && <NodeCommentForm />} {is_user && !is_loading && <NodeCommentForm nodeId={node.id} />}
</Group> </Group>
<div className={styles.panel}> <div className={styles.panel}>
@ -213,12 +213,13 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
{!is_loading && {!is_loading &&
related && related &&
related.albums && related.albums &&
!!node?.id &&
Object.keys(related.albums) Object.keys(related.albums)
.filter(album => related.albums[album].length > 0) .filter(album => related.albums[album].length > 0)
.map(album => ( .map(album => (
<NodeRelated <NodeRelated
title={ title={
<Link to={URLS.NODE_TAG_URL(node.id, encodeURIComponent(album))}> <Link to={URLS.NODE_TAG_URL(node.id!, encodeURIComponent(album))}>
{album} {album}
</Link> </Link>
} }

View file

@ -1,43 +1,42 @@
import React, { FC, useCallback, useEffect, useState } from "react"; import React, { FC, useCallback, useEffect, useState } 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 { 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 { selectUploads } from '~/redux/uploads/selectors';
import { IFileWithUUID } from "~/redux/types"; import { IFileWithUUID } from '~/redux/types';
import uuid from "uuid4"; import uuid from 'uuid4';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from "~/redux/uploads/constants"; import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants';
import { path } from 'ramda'; import { path } from 'ramda';
import * as UPLOAD_ACTIONS from "~/redux/uploads/actions"; 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';
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)) uploads: pick(['statuses', 'files'], selectUploads(state)),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
authPatchUser: AUTH_ACTIONS.authPatchUser authPatchUser: AUTH_ACTIONS.authPatchUser,
}; };
type IProps = ReturnType<typeof mapStateToProps> & type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
typeof mapDispatchToProps & {};
const ProfileAvatarUnconnected: FC<IProps> = ({ const ProfileAvatarUnconnected: FC<IProps> = ({
user: { id }, user: { id },
profile: { is_loading, user }, profile: { is_loading, user },
uploads: { statuses, files }, uploads: { statuses, files },
uploadUploadFiles, uploadUploadFiles,
authPatchUser authPatchUser,
}) => { }) => {
const can_edit = !is_loading && id && id === user.id; const can_edit = !is_loading && id && id === user?.id;
const [temp, setTemp] = useState<string>(null); const [temp, setTemp] = useState<string>('');
useEffect(() => { useEffect(() => {
if (!can_edit) return; if (!can_edit) return;
@ -45,7 +44,7 @@ const ProfileAvatarUnconnected: FC<IProps> = ({
Object.entries(statuses).forEach(([id, status]) => { Object.entries(statuses).forEach(([id, status]) => {
if (temp === id && !!status.uuid && files[status.uuid]) { if (temp === id && !!status.uuid && files[status.uuid]) {
authPatchUser({ photo: files[status.uuid] }); authPatchUser({ photo: files[status.uuid] });
setTemp(null); setTemp('');
} }
}); });
}, [statuses, files, temp, can_edit, authPatchUser]); }, [statuses, files, temp, can_edit, authPatchUser]);
@ -58,11 +57,11 @@ const ProfileAvatarUnconnected: FC<IProps> = ({
temp_id: uuid(), temp_id: uuid(),
subject: UPLOAD_SUBJECTS.AVATAR, subject: UPLOAD_SUBJECTS.AVATAR,
target: UPLOAD_TARGETS.PROFILES, target: UPLOAD_TARGETS.PROFILES,
type: UPLOAD_TYPES.IMAGE type: UPLOAD_TYPES.IMAGE,
}) })
); );
setTemp(path([0, "temp_id"], items)); setTemp(path([0, 'temp_id'], items) || '');
uploadUploadFiles(items.slice(0, 1)); uploadUploadFiles(items.slice(0, 1));
}, },
[uploadUploadFiles, setTemp] [uploadUploadFiles, setTemp]
@ -81,13 +80,15 @@ const ProfileAvatarUnconnected: FC<IProps> = ({
[onUpload, can_edit] [onUpload, can_edit]
); );
const backgroundImage = is_loading
? undefined
: `url("${user && getURL(user.photo, PRESETS.avatar)}")`;
return ( return (
<div <div
className={styles.avatar} className={styles.avatar}
style={{ style={{
backgroundImage: is_loading backgroundImage,
? null
: `url("${user && getURL(user.photo, PRESETS.avatar)}")`
}} }}
> >
{can_edit && <input type="file" onInput={onInputChange} />} {can_edit && <input type="file" onInput={onInputChange} />}
@ -100,9 +101,6 @@ const ProfileAvatarUnconnected: FC<IProps> = ({
); );
}; };
const ProfileAvatar = connect( const ProfileAvatar = connect(mapStateToProps, mapDispatchToProps)(ProfileAvatarUnconnected);
mapStateToProps,
mapDispatchToProps
)(ProfileAvatarUnconnected);
export { ProfileAvatar }; export { ProfileAvatar };

View file

@ -1,5 +1,5 @@
import React, { FC, ReactNode } from 'react'; import React, { FC, ReactNode } from 'react';
import { IUser } from '~/redux/auth/types'; import { IAuthState, IUser } from '~/redux/auth/types';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { Placeholder } from '~/components/placeholders/Placeholder'; import { Placeholder } from '~/components/placeholders/Placeholder';
@ -14,7 +14,7 @@ interface IProps {
is_loading?: boolean; is_loading?: boolean;
is_own?: boolean; is_own?: boolean;
setTab?: (tab: string) => void; setTab?: (tab: IAuthState['profile']['tab']) => void;
content?: ReactNode; content?: ReactNode;
} }
@ -26,16 +26,16 @@ const ProfileInfo: FC<IProps> = ({ user, tab, is_loading, is_own, setTab, conten
<div className={styles.field}> <div className={styles.field}>
<div className={styles.name}> <div className={styles.name}>
{is_loading ? <Placeholder width="80%" /> : user.fullname || user.username} {is_loading ? <Placeholder width="80%" /> : user?.fullname || user?.username}
</div> </div>
<div className={styles.description}> <div className={styles.description}>
{is_loading ? <Placeholder /> : getPrettyDate(user.last_seen)} {is_loading ? <Placeholder /> : getPrettyDate(user?.last_seen)}
</div> </div>
</div> </div>
</Group> </Group>
<ProfileTabs tab={tab} is_own={is_own} setTab={setTab} /> <ProfileTabs tab={tab} is_own={!!is_own} setTab={setTab} />
{content} {content}
</div> </div>

View file

@ -3,11 +3,11 @@ import { RouteComponentProps, useRouteMatch, withRouter } from 'react-router';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { NodeNoComments } from '~/components/node/NodeNoComments'; import { NodeNoComments } from '~/components/node/NodeNoComments';
import { Grid } from '~/components/containers/Grid'; import { Grid } from '~/components/containers/Grid';
import { CommentForm } from '~/components/comment/CommentForm';
import * as NODE_ACTIONS from '~/redux/node/actions'; import * as NODE_ACTIONS from '~/redux/node/actions';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { IUser } from '~/redux/auth/types'; import { IUser } from '~/redux/auth/types';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { CommentForm } from '~/components/comment/CommentForm';
const mapStateToProps = () => ({}); const mapStateToProps = () => ({});
const mapDispatchToProps = { const mapDispatchToProps = {
@ -20,10 +20,10 @@ const ProfileLayoutUnconnected: FC<IProps> = ({ history, nodeSetCoverImage }) =>
const { const {
params: { username }, params: { username },
} = useRouteMatch<{ username: string }>(); } = useRouteMatch<{ username: string }>();
const [user, setUser] = useState<IUser>(null); const [user, setUser] = useState<IUser | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (user) setUser(null); if (user) setUser(undefined);
}, [username]); }, [username]);
useEffect(() => { useEffect(() => {
@ -39,7 +39,7 @@ const ProfileLayoutUnconnected: FC<IProps> = ({ history, nodeSetCoverImage }) =>
<Grid className={styles.content}> <Grid className={styles.content}>
<div className={styles.comments}> <div className={styles.comments}>
<CommentForm id={0} /> <CommentForm nodeId={0} />
<NodeNoComments is_loading={false} /> <NodeNoComments is_loading={false} />
</div> </div>
</Grid> </Grid>

View file

@ -31,7 +31,7 @@ const ProfileMessagesUnconnected: FC<IProps> = ({
messagesRefreshMessages, messagesRefreshMessages,
}) => { }) => {
const wasAtBottom = useRef(true); const wasAtBottom = useRef(true);
const [wrap, setWrap] = useState<HTMLDivElement>(null); const [wrap, setWrap] = useState<HTMLDivElement | undefined>(undefined);
const [editingMessageId, setEditingMessageId] = useState(0); const [editingMessageId, setEditingMessageId] = useState(0);
const onEditMessage = useCallback((id: number) => setEditingMessageId(id), [setEditingMessageId]); const onEditMessage = useCallback((id: number) => setEditingMessageId(id), [setEditingMessageId]);
@ -95,31 +95,33 @@ const ProfileMessagesUnconnected: FC<IProps> = ({
if (!messages.messages.length || profile.is_loading) if (!messages.messages.length || profile.is_loading)
return <NodeNoComments is_loading={messages.is_loading_messages || profile.is_loading} />; return <NodeNoComments is_loading={messages.is_loading_messages || profile.is_loading} />;
return ( if (messages.messages.length <= 0) {
messages.messages.length > 0 && ( return null;
<div className={styles.messages} ref={storeRef}> }
{messages.messages
.filter(message => !!message.text)
.map((
message // TODO: show files / memo
) => (
<Message
message={message}
incoming={id !== message.from.id}
key={message.id}
onEdit={onEditMessage}
onDelete={onDeleteMessage}
isEditing={editingMessageId === message.id}
onCancelEdit={onCancelEdit}
onRestore={onRestoreMessage}
/>
))}
{!messages.is_loading_messages && messages.messages.length > 0 && ( return (
<div className={styles.placeholder}>Когда-нибудь здесь будут еще сообщения</div> <div className={styles.messages} ref={storeRef}>
)} {messages.messages
</div> .filter(message => !!message.text)
) .map((
message // TODO: show files / memo
) => (
<Message
message={message}
incoming={id !== message.from.id}
key={message.id}
onEdit={onEditMessage}
onDelete={onDeleteMessage}
isEditing={editingMessageId === message.id}
onCancelEdit={onCancelEdit}
onRestore={onRestoreMessage}
/>
))}
{!messages.is_loading_messages && messages.messages.length > 0 && (
<div className={styles.placeholder}>Когда-нибудь здесь будут еще сообщения</div>
)}
</div>
); );
}; };

View file

@ -1,11 +1,14 @@
import React, { FC, useMemo } from 'react'; import React, { FC, useMemo } from 'react';
import styles from './styles.module.scss';
import { IAuthState } from '~/redux/auth/types'; import { IAuthState } from '~/redux/auth/types';
import { getURL } from '~/utils/dom'; import { formatText, getURL } from '~/utils/dom';
import { PRESETS, URLS } from '~/constants/urls'; import { PRESETS, URLS } from '~/constants/urls';
import { Placeholder } from '~/components/placeholders/Placeholder'; import { Placeholder } from '~/components/placeholders/Placeholder';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import classNames from 'classnames';
import styles from './styles.module.scss';
import markdown from '~/styles/common/markdown.module.scss';
interface IProps { interface IProps {
profile: IAuthState['profile']; profile: IAuthState['profile'];
@ -26,11 +29,11 @@ const ProfilePageLeft: FC<IProps> = ({ username, profile }) => {
<div className={styles.region_wrap}> <div className={styles.region_wrap}>
<div className={styles.region}> <div className={styles.region}>
<div className={styles.name}> <div className={styles.name}>
{profile.is_loading ? <Placeholder /> : profile.user.fullname} {profile.is_loading ? <Placeholder /> : profile?.user?.fullname}
</div> </div>
<div className={styles.username}> <div className={styles.username}>
{profile.is_loading ? <Placeholder /> : `~${profile.user.username}`} {profile.is_loading ? <Placeholder /> : `~${profile?.user?.username}`}
</div> </div>
<div className={styles.menu}> <div className={styles.menu}>
@ -53,7 +56,9 @@ const ProfilePageLeft: FC<IProps> = ({ username, profile }) => {
</div> </div>
{profile && profile.user && profile.user.description && false && ( {profile && profile.user && profile.user.description && false && (
<div className={styles.description}>{profile.user.description}</div> <div className={classNames(styles.description, markdown.wrapper)}>
{formatText(profile?.user?.description || '')}
</div>
)} )}
</div> </div>
); );

View file

@ -1,38 +1,49 @@
import React, { FC } from 'react'; import React, { FC, useCallback } from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import classNames from 'classnames'; import classNames from 'classnames';
import { IAuthState } from '~/redux/auth/types';
interface IProps { interface IProps {
tab: string; tab: string;
is_own: boolean; is_own: boolean;
setTab: (tab: string) => void; setTab?: (tab: IAuthState['profile']['tab']) => void;
} }
const ProfileTabs: FC<IProps> = ({ tab, is_own, setTab }) => ( const ProfileTabs: FC<IProps> = ({ tab, is_own, setTab }) => {
<div className={styles.wrap}> const changeTab = useCallback(
<div (tab: IAuthState['profile']['tab']) => () => {
className={classNames(styles.tab, { [styles.active]: tab === 'profile' })} if (!setTab) return;
onClick={() => setTab('profile')} setTab(tab);
> },
Профиль [setTab]
);
return (
<div className={styles.wrap}>
<div
className={classNames(styles.tab, { [styles.active]: tab === 'profile' })}
onClick={changeTab('profile')}
>
Профиль
</div>
<div
className={classNames(styles.tab, { [styles.active]: tab === 'messages' })}
onClick={changeTab('messages')}
>
Сообщения
</div>
{is_own && (
<>
<div
className={classNames(styles.tab, { [styles.active]: tab === 'settings' })}
onClick={changeTab('settings')}
>
Настройки
</div>
</>
)}
</div> </div>
<div );
className={classNames(styles.tab, { [styles.active]: tab === 'messages' })} };
onClick={() => setTab('messages')}
>
Сообщения
</div>
{is_own && (
<>
<div
className={classNames(styles.tab, { [styles.active]: tab === 'settings' })}
onClick={() => setTab('settings')}
>
Настройки
</div>
</>
)}
</div>
);
export { ProfileTabs }; export { ProfileTabs };

View file

@ -56,7 +56,7 @@ const ProfileSidebarUnconnected: FC<Props> = ({
</Switch> </Switch>
<div className={classNames(styles.wrap, styles.secondary)}> <div className={classNames(styles.wrap, styles.secondary)}>
<ProfileSidebarInfo is_loading={is_loading} user={user} /> {!!user && <ProfileSidebarInfo is_loading={is_loading} user={user} />}
<ProfileSidebarMenu path={url} /> <ProfileSidebarMenu path={url} />
<Filler /> <Filler />
</div> </div>

View file

@ -35,7 +35,10 @@ const TagSidebarUnconnected: FC<Props> = ({ nodes, tagLoadNodes, tagSetNodes })
useEffect(() => { useEffect(() => {
tagLoadNodes(tag); tagLoadNodes(tag);
return () => tagSetNodes({ list: [], count: 0 });
return () => {
tagSetNodes({ list: [], count: 0 });
};
}, [tag]); }, [tag]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {

View file

@ -1,131 +1,72 @@
import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; import { api, cleanResult, errorMiddleware, resultMiddleware } from '~/utils/api';
import { API } from '~/constants/api'; import { API } from '~/constants/api';
import { INotification, IResultWithStatus } from '~/redux/types'; import { IResultWithStatus } from '~/redux/types';
import { userLoginTransform } from '~/redux/auth/transforms'; import {
import { ISocialAccount, IUser } from './types'; ApiAttachSocialRequest,
ApiAttachSocialResult,
ApiAuthGetUpdatesRequest,
ApiAuthGetUpdatesResult,
ApiAuthGetUserProfileRequest,
ApiAuthGetUserProfileResult,
ApiAuthGetUserResult,
ApiCheckRestoreCodeRequest,
ApiCheckRestoreCodeResult,
ApiDropSocialRequest,
ApiDropSocialResult,
ApiGetSocialsResult,
ApiLoginWithSocialRequest,
ApiLoginWithSocialResult,
ApiRestoreCodeRequest,
ApiRestoreCodeResult,
ApiUpdateUserRequest,
ApiUpdateUserResult,
ApiUserLoginRequest,
ApiUserLoginResult,
} from './types';
export const apiUserLogin = ({ export const apiUserLogin = ({ username, password }: ApiUserLoginRequest) =>
username,
password,
}: {
username: string;
password: string;
}): Promise<IResultWithStatus<{ token: string; status?: number }>> =>
api api
.post(API.USER.LOGIN, { username, password }) .post<ApiUserLoginResult>(API.USER.LOGIN, { username, password })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware)
.then(userLoginTransform);
export const apiAuthGetUser = ({ access }): Promise<IResultWithStatus<{ user: IUser }>> => export const apiAuthGetUser = () => api.get<ApiAuthGetUserResult>(API.USER.ME).then(cleanResult);
api
.get(API.USER.ME, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiAuthGetUserProfile = ({ export const apiAuthGetUserProfile = ({ username }: ApiAuthGetUserProfileRequest) =>
access, api.get<ApiAuthGetUserProfileResult>(API.USER.PROFILE(username)).then(cleanResult);
username,
}): Promise<IResultWithStatus<{ user: IUser }>> =>
api
.get(API.USER.PROFILE(username), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiAuthGetUpdates = ({ export const apiAuthGetUpdates = ({ exclude_dialogs, last }: ApiAuthGetUpdatesRequest) =>
access,
exclude_dialogs,
last,
}): Promise<IResultWithStatus<{
notifications: INotification[];
boris: { commented_at: string };
}>> =>
api api
.get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs, last } })) .get<ApiAuthGetUpdatesResult>(API.USER.GET_UPDATES, { params: { exclude_dialogs, last } })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);
export const apiUpdateUser = ({ access, user }): Promise<IResultWithStatus<{ user: IUser }>> => export const apiUpdateUser = ({ user }: ApiUpdateUserRequest) =>
api api.patch<ApiUpdateUserResult>(API.USER.ME, user).then(cleanResult);
.patch(API.USER.ME, user, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiRequestRestoreCode = ({ field }): Promise<IResultWithStatus<{}>> => export const apiRequestRestoreCode = ({ field }: { field: string }) =>
api api
.post(API.USER.REQUEST_CODE(), { field }) .post<{}>(API.USER.REQUEST_CODE(), { field })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);
export const apiCheckRestoreCode = ({ code }): Promise<IResultWithStatus<{}>> => export const apiCheckRestoreCode = ({ code }: ApiCheckRestoreCodeRequest) =>
api api.get<ApiCheckRestoreCodeResult>(API.USER.REQUEST_CODE(code)).then(cleanResult);
.get(API.USER.REQUEST_CODE(code))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiRestoreCode = ({ code, password }): Promise<IResultWithStatus<{}>> => export const apiRestoreCode = ({ code, password }: ApiRestoreCodeRequest) =>
api api
.post(API.USER.REQUEST_CODE(code), { password }) .post<ApiRestoreCodeResult>(API.USER.REQUEST_CODE(code), { password })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);
export const apiGetSocials = ({ export const apiGetSocials = () =>
access, api.get<ApiGetSocialsResult>(API.USER.GET_SOCIALS).then(cleanResult);
}: {
access: string;
}): Promise<IResultWithStatus<{
accounts: ISocialAccount[];
}>> =>
api
.get(API.USER.GET_SOCIALS, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiDropSocial = ({ export const apiDropSocial = ({ id, provider }: ApiDropSocialRequest) =>
access, api.delete<ApiDropSocialResult>(API.USER.DROP_SOCIAL(provider, id)).then(cleanResult);
id,
provider,
}: {
access: string;
id: string;
provider: string;
}): Promise<IResultWithStatus<{
accounts: ISocialAccount[];
}>> =>
api
.delete(API.USER.DROP_SOCIAL(provider, id), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiAttachSocial = ({ export const apiAttachSocial = ({ token }: ApiAttachSocialRequest) =>
access,
token,
}: {
access: string;
token: string;
}): Promise<IResultWithStatus<{
account: ISocialAccount;
}>> =>
api api
.post(API.USER.ATTACH_SOCIAL, { token }, configWithToken(access)) .post<ApiAttachSocialResult>(API.USER.ATTACH_SOCIAL, { token })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);
export const apiLoginWithSocial = ({ export const apiLoginWithSocial = ({ token, username, password }: ApiLoginWithSocialRequest) =>
token,
username,
password,
}: {
token: string;
username?: string;
password?: string;
}): Promise<IResultWithStatus<{
token: string;
error: string;
errors: Record<string, string>;
needs_register: boolean;
}>> =>
api api
.post(API.USER.LOGIN_WITH_SOCIAL, { token, username, password }) .post<ApiLoginWithSocialResult>(API.USER.LOGIN_WITH_SOCIAL, { token, username, password })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);

View file

@ -53,26 +53,26 @@ export const USER_ROLES = {
}; };
export const EMPTY_TOKEN: IToken = { export const EMPTY_TOKEN: IToken = {
access: null, access: '',
refresh: null, refresh: '',
}; };
export const EMPTY_USER: IUser = { export const EMPTY_USER: IUser = {
id: null, id: 0,
role: USER_ROLES.GUEST, role: USER_ROLES.GUEST,
email: null, email: '',
name: null, name: '',
username: null, username: '',
photo: null, photo: undefined,
cover: null, cover: undefined,
is_activated: false, is_activated: false,
is_user: false, is_user: false,
fullname: null, fullname: '',
description: null, description: '',
last_seen: null, last_seen: '',
last_seen_messages: null, last_seen_messages: '',
last_seen_boris: null, last_seen_boris: '',
}; };
export interface IApiUser { export interface IApiUser {

View file

@ -8,17 +8,17 @@ const HANDLERS = {
}; };
const INITIAL_STATE: IAuthState = { const INITIAL_STATE: IAuthState = {
token: null, token: '',
user: { ...EMPTY_USER }, user: { ...EMPTY_USER },
updates: { updates: {
last: null, last: '',
notifications: [], notifications: [],
boris_commented_at: null, boris_commented_at: '',
}, },
login: { login: {
error: null, error: '',
is_loading: false, is_loading: false,
is_registering: true, is_registering: true,
}, },
@ -27,7 +27,7 @@ const INITIAL_STATE: IAuthState = {
tab: 'profile', tab: 'profile',
is_loading: true, is_loading: true,
user: null, user: undefined,
patch_errors: {}, patch_errors: {},
socials: { socials: {
@ -39,20 +39,19 @@ const INITIAL_STATE: IAuthState = {
restore: { restore: {
code: '', code: '',
user: null, user: undefined,
is_loading: false, is_loading: false,
is_succesfull: false, is_succesfull: false,
error: null, error: '',
}, },
register_social: { register_social: {
errors: { errors: {
username: 'and this', username: '',
password: 'dislike this', password: '',
}, },
error: 'dont like this one', error: '',
token: token: '',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJEYXRhIjp7IlByb3ZpZGVyIjoiZ29vZ2xlIiwiSWQiOiJma2F0dXJvdkBpY2Vyb2NrZGV2LmNvbSIsIkVtYWlsIjoiZmthdHVyb3ZAaWNlcm9ja2Rldi5jb20iLCJUb2tlbiI6InlhMjkuYTBBZkg2U01EeXFGdlRaTExXckhsQm1QdGZIOFNIVGQteWlSYTFKSXNmVXluY2F6MTZ5UGhjRmxydTlDMWFtTEg0aHlHRzNIRkhrVGU0SXFUS09hVVBEREdqR2JQRVFJbGpPME9UbUp2T2RrdEtWNDVoUGpJcTB1cHVLc003UWJLSm1oRWhkMEFVa3YyejVHWlNSMjhaM2VOZVdwTEVYSGV0MW1yNyIsIkZldGNoZWQiOnsiUHJvdmlkZXIiOiJnb29nbGUiLCJJZCI6OTIyMzM3MjAzNjg1NDc3NTgwNywiTmFtZSI6IkZlZG9yIEthdHVyb3YiLCJQaG90byI6Imh0dHBzOi8vbGg2Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8ta1VMYXh0VV9jZTAvQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQU1adXVjbkEycTFReU1WLUN0RUtBclRhQzgydE52NTM2QS9waG90by5qcGcifX0sIlR5cGUiOiJvYXV0aF9jbGFpbSJ9.r1MY994BC_g4qRDoDoyNmwLs0qRzBLx6_Ez-3mHQtwg',
is_loading: false, is_loading: false,
}, },
}; };

View file

@ -1,5 +1,5 @@
import { call, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; import { call, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ERRORS, USER_ROLES } from '~/redux/auth/constants'; import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ROLES } from '~/redux/auth/constants';
import { import {
authAttachSocial, authAttachSocial,
authDropSocial, authDropSocial,
@ -48,49 +48,37 @@ import {
selectAuthRestore, selectAuthRestore,
selectAuthUpdates, selectAuthUpdates,
selectAuthUser, selectAuthUser,
selectToken,
} from './selectors'; } from './selectors';
import { IResultWithStatus, OAUTH_EVENT_TYPES, Unwrap } from '../types'; import { OAUTH_EVENT_TYPES, Unwrap } from '../types';
import { IAuthState, IUser } from './types';
import { REHYDRATE, RehydrateAction } from 'redux-persist'; import { REHYDRATE, RehydrateAction } from 'redux-persist';
import { selectModal } from '~/redux/modal/selectors'; import { selectModal } from '~/redux/modal/selectors';
import { IModalState } from '~/redux/modal';
import { DIALOGS } from '~/redux/modal/constants'; import { DIALOGS } from '~/redux/modal/constants';
import { ERRORS } from '~/constants/errors'; import { ERRORS } from '~/constants/errors';
import { messagesSet } from '~/redux/messages/actions'; import { messagesSet } from '~/redux/messages/actions';
import { SagaIterator } from 'redux-saga';
import { isEmpty } from 'ramda';
import { AxiosError } from 'axios';
export function* reqWrapper(requestAction, props = {}): ReturnType<typeof requestAction> { function* setTokenSaga({ token }: ReturnType<typeof authSetToken>) {
const access = yield select(selectToken); localStorage.setItem('token', token);
const result = yield call(requestAction, { access, ...props });
if (result && result.status === 401) {
return { error: USER_ERRORS.UNAUTHORIZED, data: {} };
}
return result;
} }
function* sendLoginRequestSaga({ username, password }: ReturnType<typeof userSendLoginRequest>) { function* sendLoginRequestSaga({ username, password }: ReturnType<typeof userSendLoginRequest>) {
if (!username || !password) return; if (!username || !password) return;
const { try {
error, const { token, user }: Unwrap<typeof apiUserLogin> = yield call(apiUserLogin, {
data: { token, user }, username,
}: IResultWithStatus<{ token: string; user: IUser }> = yield call(apiUserLogin, { password,
username, });
password,
});
if (error) { yield put(authSetToken(token));
yield put(userSetLoginError(error)); yield put(authSetUser({ ...user, is_user: true }));
return; yield put(authLoggedIn());
yield put(modalSetShown(false));
} catch (error) {
yield put(userSetLoginError(error.message));
} }
yield put(authSetToken(token));
yield put(authSetUser({ ...user, is_user: true }));
yield put(authLoggedIn());
yield put(modalSetShown(false));
} }
function* refreshUser() { function* refreshUser() {
@ -98,23 +86,18 @@ function* refreshUser() {
if (!token) return; if (!token) return;
const { try {
error, const { user }: Unwrap<typeof apiAuthGetUser> = yield call(apiAuthGetUser);
data: { user },
}: IResultWithStatus<{ user: IUser }> = yield call(reqWrapper, apiAuthGetUser);
if (error) { yield put(authSetUser({ ...user, is_user: true }));
} catch (e) {
yield put( yield put(
authSetUser({ authSetUser({
...EMPTY_USER, ...EMPTY_USER,
is_user: false, is_user: false,
}) })
); );
return;
} }
yield put(authSetUser({ ...user, is_user: true }));
} }
function* checkUserSaga({ key }: RehydrateAction) { function* checkUserSaga({ key }: RehydrateAction) {
@ -126,44 +109,43 @@ function* gotPostMessageSaga({ token }: ReturnType<typeof gotAuthPostMessage>) {
yield put(authSetToken(token)); yield put(authSetToken(token));
yield call(refreshUser); yield call(refreshUser);
const { is_shown, dialog }: IModalState = yield select(selectModal); const { is_shown, dialog }: ReturnType<typeof selectModal> = yield select(selectModal);
if (is_shown && dialog === DIALOGS.LOGIN) yield put(modalSetShown(false)); if (is_shown && dialog === DIALOGS.LOGIN) yield put(modalSetShown(false));
} }
function* logoutSaga() { function* logoutSaga() {
yield put(authSetToken(null)); yield put(authSetToken(''));
yield put(authSetUser({ ...EMPTY_USER })); yield put(authSetUser({ ...EMPTY_USER }));
yield put( yield put(
authSetUpdates({ authSetUpdates({
last: null, last: '',
notifications: [], notifications: [],
}) })
); );
} }
function* loadProfile({ username }: ReturnType<typeof authLoadProfile>) { function* loadProfile({ username }: ReturnType<typeof authLoadProfile>): SagaIterator<boolean> {
yield put(authSetProfile({ is_loading: true })); yield put(authSetProfile({ is_loading: true }));
const { try {
error, const { user }: Unwrap<typeof apiAuthGetUserProfile> = yield call(apiAuthGetUserProfile, {
data: { user }, username,
} = yield call(reqWrapper, apiAuthGetUserProfile, { username }); });
if (error || !user) { yield put(authSetProfile({ is_loading: false, user }));
yield put(messagesSet({ messages: [] }));
return true;
} catch (error) {
return false; return false;
} }
yield put(authSetProfile({ is_loading: false, user }));
yield put(messagesSet({ messages: [] }));
return true;
} }
function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenProfile>) { function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenProfile>) {
yield put(modalShowDialog(DIALOGS.PROFILE)); yield put(modalShowDialog(DIALOGS.PROFILE));
yield put(authSetProfile({ tab })); yield put(authSetProfile({ tab }));
const success: boolean = yield call(loadProfile, authLoadProfile(username)); const success: Unwrap<typeof loadProfile> = yield call(loadProfile, authLoadProfile(username));
if (!success) { if (!success) {
return yield put(modalSetShown(false)); return yield put(modalSetShown(false));
@ -171,42 +153,41 @@ function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenP
} }
function* getUpdates() { function* getUpdates() {
const user: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser); try {
const user: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser);
if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return; if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return;
const modal: IModalState = yield select(selectModal); const modal: ReturnType<typeof selectModal> = yield select(selectModal);
const profile: IAuthState['profile'] = yield select(selectAuthProfile); const profile: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile);
const { last, boris_commented_at }: IAuthState['updates'] = yield select(selectAuthUpdates); const { last, boris_commented_at }: ReturnType<typeof selectAuthUpdates> = yield select(
const exclude_dialogs = selectAuthUpdates
modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user.id ? profile.user.id : null;
const { error, data }: Unwrap<ReturnType<typeof apiAuthGetUpdates>> = yield call(
reqWrapper,
apiAuthGetUpdates,
{ exclude_dialogs, last: last || user.last_seen_messages }
);
if (error || !data) {
return;
}
if (data.notifications && data.notifications.length) {
yield put(
authSetUpdates({
last: data.notifications[0].created_at,
notifications: data.notifications,
})
); );
} const exclude_dialogs =
modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user?.id ? profile.user.id : 0;
if (data.boris && data.boris.commented_at && boris_commented_at !== data.boris.commented_at) { const data: Unwrap<typeof apiAuthGetUpdates> = yield call(apiAuthGetUpdates, {
yield put( exclude_dialogs,
authSetUpdates({ last: last || user.last_seen_messages,
boris_commented_at: data.boris.commented_at, });
})
); if (data.notifications && data.notifications.length) {
} yield put(
authSetUpdates({
last: data.notifications[0].created_at,
notifications: data.notifications,
})
);
}
if (data.boris && data.boris.commented_at && boris_commented_at !== data.boris.commented_at) {
yield put(
authSetUpdates({
boris_commented_at: data.boris.commented_at,
})
);
}
} catch (error) {}
} }
function* startPollingSaga() { function* startPollingSaga() {
@ -219,148 +200,137 @@ function* startPollingSaga() {
function* setLastSeenMessages({ last_seen_messages }: ReturnType<typeof authSetLastSeenMessages>) { function* setLastSeenMessages({ last_seen_messages }: ReturnType<typeof authSetLastSeenMessages>) {
if (!Date.parse(last_seen_messages)) return; if (!Date.parse(last_seen_messages)) return;
yield call(reqWrapper, apiUpdateUser, { user: { last_seen_messages } }); yield call(apiUpdateUser, { user: { last_seen_messages } });
} }
function* patchUser({ user }: ReturnType<typeof authPatchUser>) { function* patchUser(payload: ReturnType<typeof authPatchUser>) {
const me = yield select(selectAuthUser); const me: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser);
const { error, data } = yield call(reqWrapper, apiUpdateUser, { user }); try {
const { user }: Unwrap<typeof apiUpdateUser> = yield call(apiUpdateUser, {
user: payload.user,
});
if (error || !data.user || data.errors) { yield put(authSetUser({ ...me, ...user }));
return yield put(authSetProfile({ patch_errors: data.errors })); yield put(authSetProfile({ user: { ...me, ...user }, tab: 'profile' }));
} catch (error) {
if (isEmpty(error.response.data.errors)) return;
yield put(authSetProfile({ patch_errors: error.response.data.errors }));
} }
yield put(authSetUser({ ...me, ...data.user }));
yield put(authSetProfile({ user: { ...me, ...data.user }, tab: 'profile' }));
} }
function* requestRestoreCode({ field }: ReturnType<typeof authRequestRestoreCode>) { function* requestRestoreCode({ field }: ReturnType<typeof authRequestRestoreCode>) {
if (!field) return; if (!field) return;
yield put(authSetRestore({ error: null, is_loading: true })); try {
const { error, data } = yield call(apiRequestRestoreCode, { field }); yield put(authSetRestore({ error: '', is_loading: true }));
yield call(apiRequestRestoreCode, {
field,
});
if (data.error || error) { yield put(authSetRestore({ is_loading: false, is_succesfull: true }));
return yield put(authSetRestore({ is_loading: false, error: data.error || error })); } catch (error) {
return yield put(authSetRestore({ is_loading: false, error: error.message }));
} }
yield put(authSetRestore({ is_loading: false, is_succesfull: true }));
} }
function* showRestoreModal({ code }: ReturnType<typeof authShowRestoreModal>) { function* showRestoreModal({ code }: ReturnType<typeof authShowRestoreModal>) {
if (!code && !code.length) { try {
return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false })); if (!code && !code.length) {
} return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false }));
}
yield put(authSetRestore({ user: null, is_loading: true })); yield put(authSetRestore({ user: undefined, is_loading: true }));
const { error, data } = yield call(apiCheckRestoreCode, { code }); const data: Unwrap<typeof apiCheckRestoreCode> = yield call(apiCheckRestoreCode, { code });
if (data.error || error || !data.user) { yield put(authSetRestore({ user: data.user, code, is_loading: false }));
yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD));
} catch (error) {
yield put( yield put(
authSetRestore({ is_loading: false, error: data.error || error || ERRORS.CODE_IS_INVALID }) authSetRestore({ is_loading: false, error: error.message || ERRORS.CODE_IS_INVALID })
); );
yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD));
return yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD));
} }
yield put(authSetRestore({ user: data.user, code, is_loading: false }));
yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD));
} }
function* restorePassword({ password }: ReturnType<typeof authRestorePassword>) { function* restorePassword({ password }: ReturnType<typeof authRestorePassword>) {
if (!password) return; try {
if (!password) return;
yield put(authSetRestore({ is_loading: true })); yield put(authSetRestore({ is_loading: true }));
const { code } = yield select(selectAuthRestore); const { code }: ReturnType<typeof selectAuthRestore> = yield select(selectAuthRestore);
if (!code) { if (!code) {
return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false })); return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false }));
} }
const { error, data } = yield call(apiRestoreCode, { code, password }); const data: Unwrap<typeof apiRestoreCode> = yield call(apiRestoreCode, { code, password });
if (data.error || error || !data.user || !data.token) { yield put(authSetToken(data.token));
yield put(authSetUser(data.user));
yield put(authSetRestore({ is_loading: false, is_succesfull: true, error: '' }));
yield call(refreshUser);
} catch (error) {
return yield put( return yield put(
authSetRestore({ is_loading: false, error: data.error || error || ERRORS.CODE_IS_INVALID }) authSetRestore({ is_loading: false, error: error.message || ERRORS.CODE_IS_INVALID })
); );
} }
yield put(authSetToken(data.token));
yield put(authSetUser(data.user));
yield put(authSetRestore({ is_loading: false, is_succesfull: true, error: null }));
yield call(refreshUser);
} }
function* getSocials() { function* getSocials() {
yield put(authSetSocials({ is_loading: true, error: '' }));
try { try {
const { data, error }: Unwrap<ReturnType<typeof apiGetSocials>> = yield call( yield put(authSetSocials({ is_loading: true, error: '' }));
reqWrapper, const data: Unwrap<typeof apiGetSocials> = yield call(apiGetSocials);
apiGetSocials, yield put(authSetSocials({ accounts: data.accounts }));
{} } catch (error) {
); yield put(authSetSocials({ error: error.message }));
} finally {
if (error) { yield put(authSetSocials({ is_loading: false }));
throw new Error(error);
}
yield put(authSetSocials({ is_loading: false, accounts: data.accounts, error: '' }));
} catch (e) {
yield put(authSetSocials({ is_loading: false, error: e.toString() }));
} }
} }
// TODO: start from here
function* dropSocial({ provider, id }: ReturnType<typeof authDropSocial>) { function* dropSocial({ provider, id }: ReturnType<typeof authDropSocial>) {
try { try {
yield put(authSetSocials({ error: '' })); yield put(authSetSocials({ error: '' }));
const { error }: Unwrap<ReturnType<typeof apiDropSocial>> = yield call( yield call(apiDropSocial, {
reqWrapper, id,
apiDropSocial, provider,
{ id, provider } });
);
if (error) {
throw new Error(error);
}
yield call(getSocials); yield call(getSocials);
} catch (e) { } catch (error) {
yield put(authSetSocials({ error: e.message })); yield put(authSetSocials({ error: error.message }));
} }
} }
function* attachSocial({ token }: ReturnType<typeof authAttachSocial>) { function* attachSocial({ token }: ReturnType<typeof authAttachSocial>) {
if (!token) return;
try { try {
if (!token) return;
yield put(authSetSocials({ error: '', is_loading: true })); yield put(authSetSocials({ error: '', is_loading: true }));
const { data, error }: Unwrap<ReturnType<typeof apiAttachSocial>> = yield call( const data: Unwrap<typeof apiAttachSocial> = yield call(apiAttachSocial, {
reqWrapper, token,
apiAttachSocial, });
{ token }
);
if (error) {
throw new Error(error);
}
const { const {
socials: { accounts }, socials: { accounts },
}: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile); }: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile);
if (accounts.some(it => it.id === data.account.id && it.provider === data.account.provider)) { if (accounts.some(it => it.id === data.account.id && it.provider === data.account.provider)) {
yield put(authSetSocials({ is_loading: false })); return;
} else {
yield put(authSetSocials({ is_loading: false, accounts: [...accounts, data.account] }));
} }
yield put(authSetSocials({ accounts: [...accounts, data.account] }));
} catch (e) { } catch (e) {
yield put(authSetSocials({ is_loading: false, error: e.message })); yield put(authSetSocials({ error: e.message }));
} finally {
yield put(authSetSocials({ is_loading: false }));
} }
} }
@ -368,21 +338,9 @@ function* loginWithSocial({ token }: ReturnType<typeof authLoginWithSocial>) {
try { try {
yield put(userSetLoginError('')); yield put(userSetLoginError(''));
const { const data: Unwrap<typeof apiLoginWithSocial> = yield call(apiLoginWithSocial, {
data, token,
error, });
}: Unwrap<ReturnType<typeof apiLoginWithSocial>> = yield call(apiLoginWithSocial, { token });
// Backend asks us for account registration
if (data?.needs_register) {
yield put(authSetRegisterSocial({ token }));
yield put(modalShowDialog(DIALOGS.LOGIN_SOCIAL_REGISTER));
return;
}
if (error) {
throw new Error(error);
}
if (data.token) { if (data.token) {
yield put(authSetToken(data.token)); yield put(authSetToken(data.token));
@ -390,8 +348,21 @@ function* loginWithSocial({ token }: ReturnType<typeof authLoginWithSocial>) {
yield put(modalSetShown(false)); yield put(modalSetShown(false));
return; return;
} }
} catch (e) { } catch (error) {
yield put(userSetLoginError(e.message)); const { dialog }: ReturnType<typeof selectModal> = yield select(selectModal);
const data = (error as AxiosError<{
needs_register: boolean;
errors: Record<'username' | 'password', string>;
}>).response?.data;
// Backend asks us for account registration
if (dialog !== DIALOGS.LOGIN_SOCIAL_REGISTER && data?.needs_register) {
yield put(authSetRegisterSocial({ token }));
yield put(modalShowDialog(DIALOGS.LOGIN_SOCIAL_REGISTER));
return;
}
yield put(userSetLoginError(error.message));
} }
} }
@ -414,24 +385,15 @@ function* authRegisterSocial({ username, password }: ReturnType<typeof authSendR
try { try {
yield put(authSetRegisterSocial({ error: '' })); yield put(authSetRegisterSocial({ error: '' }));
const { token }: Unwrap<ReturnType<typeof selectAuthRegisterSocial>> = yield select( const { token }: ReturnType<typeof selectAuthRegisterSocial> = yield select(
selectAuthRegisterSocial selectAuthRegisterSocial
); );
const { data, error }: Unwrap<ReturnType<typeof apiLoginWithSocial>> = yield call( const data: Unwrap<typeof apiLoginWithSocial> = yield call(apiLoginWithSocial, {
apiLoginWithSocial, token,
{ username,
token, password,
username, });
password,
}
);
if (data?.errors) {
yield put(authSetRegisterSocialErrors(data.errors));
} else if (data?.error) {
throw new Error(error);
}
if (data.token) { if (data.token) {
yield put(authSetToken(data.token)); yield put(authSetToken(data.token));
@ -439,8 +401,18 @@ function* authRegisterSocial({ username, password }: ReturnType<typeof authSendR
yield put(modalSetShown(false)); yield put(modalSetShown(false));
return; return;
} }
} catch (e) { } catch (error) {
yield put(authSetRegisterSocial({ error: e.message })); const data = (error as AxiosError<{
needs_register: boolean;
errors: Record<'username' | 'password', string>;
}>).response?.data;
if (data?.errors) {
yield put(authSetRegisterSocialErrors(data.errors));
return;
}
yield put(authSetRegisterSocial({ error: error.message }));
} }
} }
@ -449,6 +421,7 @@ function* authSaga() {
yield takeLatest([REHYDRATE, AUTH_USER_ACTIONS.LOGGED_IN], startPollingSaga); yield takeLatest([REHYDRATE, AUTH_USER_ACTIONS.LOGGED_IN], startPollingSaga);
yield takeLatest(AUTH_USER_ACTIONS.LOGOUT, logoutSaga); yield takeLatest(AUTH_USER_ACTIONS.LOGOUT, logoutSaga);
yield takeLatest(AUTH_USER_ACTIONS.SET_TOKEN, setTokenSaga);
yield takeLatest(AUTH_USER_ACTIONS.SEND_LOGIN_REQUEST, sendLoginRequestSaga); yield takeLatest(AUTH_USER_ACTIONS.SEND_LOGIN_REQUEST, sendLoginRequestSaga);
yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga); yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga);
yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile); yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile);

View file

@ -5,7 +5,7 @@ export const selectUser = (state: IState) => state.auth.user;
export const selectToken = (state: IState) => state.auth.token; export const selectToken = (state: IState) => state.auth.token;
export const selectAuthLogin = (state: IState) => state.auth.login; export const selectAuthLogin = (state: IState) => state.auth.login;
export const selectAuthProfile = (state: IState) => state.auth.profile; export const selectAuthProfile = (state: IState) => state.auth.profile;
export const selectAuthProfileUsername = (state: IState) => state.auth.profile.user.username; export const selectAuthProfileUsername = (state: IState) => state.auth.profile.user?.username;
export const selectAuthUser = (state: IState) => state.auth.user; export const selectAuthUser = (state: IState) => state.auth.user;
export const selectAuthUpdates = (state: IState) => state.auth.updates; export const selectAuthUpdates = (state: IState) => state.auth.updates;
export const selectAuthRestore = (state: IState) => state.auth.restore; export const selectAuthRestore = (state: IState) => state.auth.restore;

View file

@ -1,13 +1,18 @@
import { IResultWithStatus } from '~/redux/types'; import { IResultWithStatus } from '~/redux/types';
import { HTTP_RESPONSES } from '~/utils/api'; import { HTTP_RESPONSES } from '~/utils/api';
export const userLoginTransform = ({ status, data, error }: IResultWithStatus<any>): IResultWithStatus<any> => { export const userLoginTransform = ({
status,
data,
error,
}: IResultWithStatus<any>): IResultWithStatus<any> => {
switch (true) { switch (true) {
case (status === HTTP_RESPONSES.UNAUTHORIZED || !data.token) && status !== HTTP_RESPONSES.CONNECTION_REFUSED: case (status === HTTP_RESPONSES.UNAUTHORIZED || !data.token) &&
status !== HTTP_RESPONSES.CONNECTION_REFUSED:
return { status, data, error: 'Пользователь не найден' }; return { status, data, error: 'Пользователь не найден' };
case status === 200: case status === 200:
return { status, data, error: null }; return { status, data, error: '' };
default: default:
return { status, data, error: error || 'Неизвестная ошибка' }; return { status, data, error: error || 'Неизвестная ошибка' };

View file

@ -1,4 +1,4 @@
import { IFile, INotification } from '../types'; import { IFile, INotification, IResultWithStatus } from '../types';
export interface IToken { export interface IToken {
access: string; access: string;
@ -10,8 +10,8 @@ export interface IUser {
username: string; username: string;
email: string; email: string;
role: string; role: string;
photo: IFile; photo?: IFile;
cover: IFile; cover?: IFile;
name: string; name: string;
fullname: string; fullname: string;
description: string; description: string;
@ -53,7 +53,7 @@ export type IAuthState = Readonly<{
tab: 'profile' | 'messages' | 'settings'; tab: 'profile' | 'messages' | 'settings';
is_loading: boolean; is_loading: boolean;
user: IUser; user?: IUser;
patch_errors: Record<string, string>; patch_errors: Record<string, string>;
socials: { socials: {
@ -65,7 +65,7 @@ export type IAuthState = Readonly<{
restore: { restore: {
code: string; code: string;
user: Pick<IUser, 'username' | 'photo'>; user?: Pick<IUser, 'username' | 'photo'>;
is_loading: boolean; is_loading: boolean;
is_succesfull: boolean; is_succesfull: boolean;
error: string; error: string;
@ -81,3 +81,52 @@ export type IAuthState = Readonly<{
is_loading: boolean; is_loading: boolean;
}; };
}>; }>;
export type ApiWithTokenRequest = { access: string };
export type ApiUserLoginRequest = Record<'username' | 'password', string>;
export type ApiUserLoginResult = { token: string; user: IUser };
export type ApiAuthGetUserRequest = {};
export type ApiAuthGetUserResult = { user: IUser };
export type ApiUpdateUserRequest = { user: Partial<IUser> };
export type ApiUpdateUserResult = { user: IUser; errors: Record<Partial<keyof IUser>, string> };
export type ApiAuthGetUserProfileRequest = { username: string };
export type ApiAuthGetUserProfileResult = { user: IUser };
export type ApiAuthGetUpdatesRequest = {
exclude_dialogs: number;
last: string;
};
export type ApiAuthGetUpdatesResult = {
notifications: INotification[];
boris: { commented_at: string };
};
export type ApiCheckRestoreCodeRequest = { code: string };
export type ApiCheckRestoreCodeResult = { user: IUser };
export type ApiRestoreCodeRequest = { code: string; password: string };
export type ApiRestoreCodeResult = { token: string; user: IUser };
export type ApiGetSocialsResult = { accounts: ISocialAccount[] };
export type ApiDropSocialRequest = { id: string; provider: string };
export type ApiDropSocialResult = { accounts: ISocialAccount[] };
export type ApiAttachSocialRequest = { token: string };
export type ApiAttachSocialResult = { account: ISocialAccount };
export type ApiLoginWithSocialRequest = {
token: string;
username?: string;
password?: string;
};
export type ApiLoginWithSocialResult = {
token: string;
errors: Record<string, string>;
needs_register: boolean;
};

View file

@ -1,13 +1,10 @@
import git from '~/stats/git.json'; import git from '~/stats/git.json';
import { API } from '~/constants/api'; import { API } from '~/constants/api';
import { api, resultMiddleware, errorMiddleware } from '~/utils/api'; import { api, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api';
import { IBorisState, IStatBackend } from './reducer'; import { IBorisState, IStatBackend } from './reducer';
import { IResultWithStatus } from '../types'; import { IResultWithStatus } from '../types';
export const getBorisGitStats = (): Promise<IBorisState['stats']['git']> => Promise.resolve(git); export const getBorisGitStats = () => Promise.resolve<IBorisState['stats']['git']>(git);
export const getBorisBackendStats = (): Promise<IResultWithStatus<IStatBackend>> => export const getBorisBackendStats = () =>
api api.get<IStatBackend>(API.BORIS.GET_BACKEND_STATS).then(cleanResult);
.get(API.BORIS.GET_BACKEND_STATS)
.then(resultMiddleware)
.catch(errorMiddleware);

View file

@ -31,7 +31,7 @@ export type IStatBackend = {
export type IBorisState = Readonly<{ export type IBorisState = Readonly<{
stats: { stats: {
git: Partial<IStatGitRow>[]; git: Partial<IStatGitRow>[];
backend: IStatBackend; backend?: IStatBackend;
is_loading: boolean; is_loading: boolean;
}; };
}>; }>;
@ -39,7 +39,7 @@ export type IBorisState = Readonly<{
const BORIS_INITIAL_STATE: IBorisState = { const BORIS_INITIAL_STATE: IBorisState = {
stats: { stats: {
git: [], git: [],
backend: null, backend: undefined,
is_loading: false, is_loading: false,
}, },
}; };

View file

@ -5,17 +5,17 @@ import { getBorisGitStats, getBorisBackendStats } from './api';
import { Unwrap } from '../types'; import { Unwrap } from '../types';
function* loadStats() { function* loadStats() {
yield put(borisSetStats({ is_loading: true }));
try { try {
const git: Unwrap<ReturnType<typeof getBorisGitStats>> = yield call(getBorisGitStats); yield put(borisSetStats({ is_loading: true }));
const backend: Unwrap<ReturnType<typeof getBorisBackendStats>> = yield call(
getBorisBackendStats
);
yield put(borisSetStats({ git, backend: backend.data, is_loading: false })); const git: Unwrap<typeof getBorisGitStats> = yield call(getBorisGitStats);
const backend: Unwrap<typeof getBorisBackendStats> = yield call(getBorisBackendStats);
yield put(borisSetStats({ git, backend }));
} catch (e) { } catch (e) {
yield put(borisSetStats({ git: [], backend: null, is_loading: false })); yield put(borisSetStats({ git: [], backend: undefined }));
} finally {
yield put(borisSetStats({ is_loading: false }));
} }
} }

View file

@ -1,8 +1,8 @@
import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api'; import { api, cleanResult, configWithToken } from '~/utils/api';
import { INode, IResultWithStatus } from '../types'; import { INode, IResultWithStatus } from '../types';
import { API } from '~/constants/api'; import { API } from '~/constants/api';
import { flowSetCellView } from '~/redux/flow/actions'; import { PostCellViewRequest, PostCellViewResult } from '~/redux/node/types';
import { IFlowState } from './reducer'; import { GetSearchResultsRequest, GetSearchResultsResult } from '~/redux/flow/types';
export const postNode = ({ export const postNode = ({
access, access,
@ -11,32 +11,14 @@ export const postNode = ({
access: string; access: string;
node: INode; node: INode;
}): Promise<IResultWithStatus<INode>> => }): Promise<IResultWithStatus<INode>> =>
api api.post(API.NODE.SAVE, { node }, configWithToken(access)).then(cleanResult);
.post(API.NODE.SAVE, { node }, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const postCellView = ({ export const postCellView = ({ id, flow }: PostCellViewRequest) =>
id,
flow,
access,
}: ReturnType<typeof flowSetCellView> & { access: string }): Promise<IResultWithStatus<{
is_liked: INode['is_liked'];
}>> =>
api api
.post(API.NODE.SET_CELL_VIEW(id), { flow }, configWithToken(access)) .post<PostCellViewResult>(API.NODE.SET_CELL_VIEW(id), { flow })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);
export const getSearchResults = ({ export const getSearchResults = ({ text, skip = 0 }: GetSearchResultsRequest) =>
access,
text,
skip = 0,
}: IFlowState['search'] & {
access: string;
skip: number;
}): Promise<IResultWithStatus<{ nodes: INode[]; total: number }>> =>
api api
.get(API.SEARCH.NODES, configWithToken(access, { params: { text, skip } })) .get<GetSearchResultsResult>(API.SEARCH.NODES, { params: { text, skip } })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);

View file

@ -31,7 +31,7 @@ const INITIAL_STATE: IFlowState = {
is_loading_more: false, is_loading_more: false,
}, },
is_loading: false, is_loading: false,
error: null, error: '',
}; };
export default createReducer(INITIAL_STATE, FLOW_HANDLERS); export default createReducer(INITIAL_STATE, FLOW_HANDLERS);

View file

@ -1,182 +1,188 @@
import { takeLatest, call, put, select, takeLeading, delay, race, take } from 'redux-saga/effects'; import { call, delay, put, race, select, take, takeLatest, takeLeading } from 'redux-saga/effects';
import { REHYDRATE } from 'redux-persist'; import { REHYDRATE } from 'redux-persist';
import { FLOW_ACTIONS } from './constants'; import { FLOW_ACTIONS } from './constants';
import { getNodeDiff } from '../node/api'; import { getNodeDiff } from '../node/api';
import { import {
flowSetNodes,
flowSetCellView,
flowSetHeroes,
flowSetRecent,
flowSetUpdated,
flowSetFlow,
flowChangeSearch, flowChangeSearch,
flowSetCellView,
flowSetFlow,
flowSetHeroes,
flowSetNodes,
flowSetRecent,
flowSetSearch, flowSetSearch,
flowSetUpdated,
} from './actions'; } from './actions';
import { IResultWithStatus, INode, Unwrap } from '../types'; import { Unwrap } from '../types';
import { selectFlowNodes, selectFlow } from './selectors'; import { selectFlow, selectFlowNodes } from './selectors';
import { reqWrapper } from '../auth/sagas'; import { getSearchResults, postCellView } from './api';
import { postCellView, getSearchResults } from './api';
import { IFlowState } from './reducer';
import { uniq } from 'ramda'; import { uniq } from 'ramda';
function hideLoader() { function hideLoader() {
document.getElementById('main_loader').style.display = 'none'; const loader = document.getElementById('main_loader');
}
function* onGetFlow() { if (!loader) {
const {
flow: { _persist },
} = yield select();
if (!_persist.rehydrated) return;
const stored: IFlowState['nodes'] = yield select(selectFlowNodes);
if (stored.length) {
hideLoader();
}
yield put(flowSetFlow({ is_loading: true }));
const {
data: { before = [], after = [], heroes = [], recent = [], updated = [], valid = null },
}: IResultWithStatus<{
before: IFlowState['nodes'];
after: IFlowState['nodes'];
heroes: IFlowState['heroes'];
recent: IFlowState['recent'];
updated: IFlowState['updated'];
valid: INode['id'][];
}> = yield call(reqWrapper, getNodeDiff, {
start: new Date().toISOString(),
end: new Date().toISOString(),
with_heroes: true,
with_updated: true,
with_recent: true,
with_valid: false,
});
const result = uniq([...(before || []), ...(after || [])]);
yield put(flowSetFlow({ is_loading: false, nodes: result }));
if (heroes.length) yield put(flowSetHeroes(heroes));
if (recent.length) yield put(flowSetRecent(recent));
if (updated.length) yield put(flowSetUpdated(updated));
if (!stored.length) hideLoader();
}
function* onSetCellView({ id, flow }: ReturnType<typeof flowSetCellView>) {
const nodes = yield select(selectFlowNodes);
yield put(flowSetNodes(nodes.map(node => (node.id === id ? { ...node, flow } : node))));
const { data, error } = yield call(reqWrapper, postCellView, { id, flow });
// TODO: error handling
}
function* getMore() {
yield put(flowSetFlow({ is_loading: true }));
const nodes: IFlowState['nodes'] = yield select(selectFlowNodes);
const start = nodes && nodes[0] && nodes[0].created_at;
const end = nodes && nodes[nodes.length - 1] && nodes[nodes.length - 1].created_at;
const { error, data } = yield call(reqWrapper, getNodeDiff, {
start,
end,
with_heroes: false,
with_updated: true,
with_recent: true,
with_valid: true,
});
if (error || !data) return;
const result = uniq([
...(data.before || []),
...(data.valid ? nodes.filter(node => data.valid.includes(node.id)) : nodes),
...(data.after || []),
]);
yield put(
flowSetFlow({
is_loading: false,
nodes: result,
...(data.recent ? { recent: data.recent } : {}),
...(data.updated ? { updated: data.updated } : {}),
})
);
yield delay(1000);
}
function* changeSearch({ search }: ReturnType<typeof flowChangeSearch>) {
yield put(
flowSetSearch({
...search,
is_loading: !!search.text,
})
);
if (!search.text) return;
yield delay(500);
const { data, error }: Unwrap<ReturnType<typeof getSearchResults>> = yield call(
reqWrapper,
getSearchResults,
{
...search,
}
);
if (error) {
yield put(flowSetSearch({ is_loading: false, results: [], total: 0 }));
return; return;
} }
yield put( loader.style.display = 'none';
flowSetSearch({ }
is_loading: false,
results: data.nodes, function* onGetFlow() {
total: data.total, try {
}) const {
); flow: { _persist },
} = yield select();
if (!_persist.rehydrated) return;
const stored: ReturnType<typeof selectFlowNodes> = yield select(selectFlowNodes);
if (stored.length) {
hideLoader();
}
yield put(flowSetFlow({ is_loading: true }));
const {
before = [],
after = [],
heroes = [],
recent = [],
updated = [],
}: Unwrap<typeof getNodeDiff> = yield call(getNodeDiff, {
start: new Date().toISOString(),
end: new Date().toISOString(),
with_heroes: true,
with_updated: true,
with_recent: true,
with_valid: false,
});
const result = uniq([...(before || []), ...(after || [])]);
yield put(flowSetFlow({ is_loading: false, nodes: result }));
if (heroes.length) yield put(flowSetHeroes(heroes));
if (recent.length) yield put(flowSetRecent(recent));
if (updated.length) yield put(flowSetUpdated(updated));
if (!stored.length) hideLoader();
} catch (error) {
console.log(error);
}
}
function* onSetCellView({ id, flow }: ReturnType<typeof flowSetCellView>) {
try {
const nodes: ReturnType<typeof selectFlowNodes> = yield select(selectFlowNodes);
yield put(flowSetNodes(nodes.map(node => (node.id === id ? { ...node, flow } : node))));
yield call(postCellView, { id, flow });
} catch (error) {
console.log(error);
}
}
function* getMore() {
try {
yield put(flowSetFlow({ is_loading: true }));
const nodes: ReturnType<typeof selectFlowNodes> = yield select(selectFlowNodes);
const start = nodes && nodes[0] && nodes[0].created_at;
const end = nodes && nodes[nodes.length - 1] && nodes[nodes.length - 1].created_at;
const data: Unwrap<typeof getNodeDiff> = yield call(getNodeDiff, {
start,
end,
with_heroes: false,
with_updated: true,
with_recent: true,
with_valid: true,
});
const result = uniq([
...(data.before || []),
...(data.valid ? nodes.filter(node => data.valid.includes(node.id)) : nodes),
...(data.after || []),
]);
yield put(
flowSetFlow({
is_loading: false,
nodes: result,
...(data.recent ? { recent: data.recent } : {}),
...(data.updated ? { updated: data.updated } : {}),
})
);
yield delay(1000);
} catch (error) {}
}
function* changeSearch({ search }: ReturnType<typeof flowChangeSearch>) {
try {
yield put(
flowSetSearch({
...search,
is_loading: !!search.text,
})
);
if (!search.text) return;
yield delay(500);
const data: Unwrap<typeof getSearchResults> = yield call(getSearchResults, {
text: search.text,
});
yield put(
flowSetSearch({
results: data.nodes,
total: data.total,
})
);
} catch (error) {
yield put(flowSetSearch({ results: [], total: 0 }));
} finally {
yield put(flowSetSearch({ is_loading: false }));
}
} }
function* loadMoreSearch() { function* loadMoreSearch() {
yield put( try {
flowSetSearch({ yield put(
is_loading_more: true, flowSetSearch({
}) is_loading_more: true,
); })
);
const { search }: ReturnType<typeof selectFlow> = yield select(selectFlow); const { search }: ReturnType<typeof selectFlow> = yield select(selectFlow);
const { const { result, delay }: { result: Unwrap<typeof getSearchResults>; delay: any } = yield race({
result, result: call(getSearchResults, {
delay, ...search,
}: { result: Unwrap<ReturnType<typeof getSearchResults>>; delay: any } = yield race({ skip: search.results.length,
result: call(reqWrapper, getSearchResults, { }),
...search, delay: take(FLOW_ACTIONS.CHANGE_SEARCH),
skip: search.results.length, });
}),
delay: take(FLOW_ACTIONS.CHANGE_SEARCH),
});
if (delay || result.error) { if (delay) {
return put(flowSetSearch({ is_loading_more: false })); return;
}
yield put(
flowSetSearch({
results: [...search.results, ...result.nodes],
total: result.total,
})
);
} catch (error) {
yield put(
flowSetSearch({
is_loading_more: false,
})
);
} }
yield put(
flowSetSearch({
results: [...search.results, ...result.data.nodes],
total: result.data.total,
is_loading_more: false,
})
);
} }
export default function* nodeSaga() { export default function* nodeSaga() {

10
src/redux/flow/types.ts Normal file
View file

@ -0,0 +1,10 @@
import { INode } from '~/redux/types';
export type GetSearchResultsRequest = {
text: string;
skip?: number;
};
export type GetSearchResultsResult = {
nodes: INode[];
total: number;
};

View file

@ -1,48 +1,29 @@
import { IMessage, IResultWithStatus } from '~/redux/types'; import { api, cleanResult } from '~/utils/api';
import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api';
import { API } from '~/constants/api'; import { API } from '~/constants/api';
import {
ApiDeleteMessageRequest,
ApiDeleteMessageResult,
ApiGetUserMessagesRequest,
ApiGetUserMessagesResponse,
ApiSendMessageRequest,
ApiSendMessageResult,
} from '~/redux/messages/types';
export const apiMessagesGetUserMessages = ({ export const apiGetUserMessages = ({ username, after, before }: ApiGetUserMessagesRequest) =>
access,
username,
after,
before,
}: {
access: string;
username: string;
after?: string;
before?: string;
}): Promise<IResultWithStatus<{ messages: IMessage[] }>> =>
api api
.get(API.USER.MESSAGES(username), configWithToken(access, { params: { after, before } })) .get<ApiGetUserMessagesResponse>(API.USER.MESSAGES(username), {
.then(resultMiddleware) params: { after, before },
.catch(errorMiddleware); })
.then(cleanResult);
export const apiMessagesSendMessage = ({ export const apiSendMessage = ({ username, message }: ApiSendMessageRequest) =>
access,
username,
message,
}): Promise<IResultWithStatus<{ message: IMessage }>> =>
api api
.post(API.USER.MESSAGE_SEND(username), { message }, configWithToken(access)) .post<ApiSendMessageResult>(API.USER.MESSAGE_SEND(username), { message })
.then(resultMiddleware) .then(cleanResult);
.catch(errorMiddleware);
export const apiMessagesDeleteMessage = ({ export const apiDeleteMessage = ({ username, id, is_locked }: ApiDeleteMessageRequest) =>
access,
username,
id,
is_locked,
}: {
access: string;
username: string;
id: number;
is_locked: boolean;
}): Promise<IResultWithStatus<{ message: IMessage }>> =>
api api
.delete( .delete<ApiDeleteMessageResult>(API.USER.MESSAGE_DELETE(username, id), {
API.USER.MESSAGE_DELETE(username, id), params: { is_locked },
configWithToken(access, { params: { is_locked } }) })
) .then(cleanResult);
.then(resultMiddleware)
.catch(errorMiddleware);

View file

@ -12,7 +12,7 @@ export interface IMessagesState {
const INITIAL_STATE: IMessagesState = { const INITIAL_STATE: IMessagesState = {
is_loading_messages: true, is_loading_messages: true,
is_sending_messages: false, is_sending_messages: false,
error: null, error: '',
messages: [], messages: [],
}; };

Some files were not shown because too many files have changed in this diff Show more