mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 12:56:41 +07:00
Merge branch 'develop' into 23-labs
This commit is contained in:
commit
44bbc4cd4c
147 changed files with 3292 additions and 2627 deletions
5
.env
5
.env
|
@ -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
1
.gitignore
vendored
|
@ -2,3 +2,4 @@
|
||||||
/npm-debug.log
|
/npm-debug.log
|
||||||
/.idea
|
/.idea
|
||||||
/dist
|
/dist
|
||||||
|
/.env
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
20
package.json
20
package.json
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 };
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
@import "src/styles/variables";
|
||||||
|
|
||||||
|
.attaches {
|
||||||
|
@include outer_shadow();
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper {
|
||||||
|
z-index: 10000 !important;
|
||||||
|
}
|
115
src/components/comment/CommentFormFormatButtons/index.tsx
Normal file
115
src/components/comment/CommentFormFormatButtons/index.tsx
Normal 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 };
|
|
@ -0,0 +1,12 @@
|
||||||
|
@import '~/styles/variables.scss';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: 32px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
@media(max-width: 480px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,15 +28,4 @@
|
||||||
:global(.green) {
|
:global(.green) {
|
||||||
color: $wisegreen;
|
color: $wisegreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
p {
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
display: inline-flex;
|
|
||||||
height: 1em;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
36
src/components/comment/LocalCommentFormTextarea/index.tsx
Normal file
36
src/components/comment/LocalCommentFormTextarea/index.tsx
Normal 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 };
|
|
@ -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]);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -2,5 +2,5 @@
|
||||||
|
|
||||||
.helper {
|
.helper {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
z-index: 10 !important;
|
z-index: 10000 !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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="Адрес видео" />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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%"
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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]
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 '';
|
||||||
};
|
};
|
||||||
|
|
|
@ -87,7 +87,10 @@ const TagAutocompleteUnconnected: FC<Props> = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tagSetAutocomplete({ options: [] });
|
tagSetAutocomplete({ options: [] });
|
||||||
return () => tagSetAutocomplete({ options: [] });
|
|
||||||
|
return () => {
|
||||||
|
tagSetAutocomplete({ options: [] });
|
||||||
|
};
|
||||||
}, [tagSetAutocomplete]);
|
}, [tagSetAutocomplete]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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`,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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]: 'Подключение не удалось',
|
||||||
};
|
};
|
||||||
|
|
|
@ -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}`,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
|
|
@ -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 }) => (
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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);
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 || 'Неизвестная ошибка' };
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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);
|
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
10
src/redux/flow/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
|
||||||
|
export type GetSearchResultsRequest = {
|
||||||
|
text: string;
|
||||||
|
skip?: number;
|
||||||
|
};
|
||||||
|
export type GetSearchResultsResult = {
|
||||||
|
nodes: INode[];
|
||||||
|
total: number;
|
||||||
|
};
|
|
@ -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);
|
|
||||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue