1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-05-04 17:16:40 +07:00

Merge branch 'develop' into 23-labs

This commit is contained in:
Fedor Katurov 2021-03-05 17:31:33 +07:00
commit 44bbc4cd4c
147 changed files with 3292 additions and 2627 deletions
.env.gitignorecraco.config.jspackage.json
src
components
comment
Comment
CommentContent
CommentEmbedBlock
CommentForm
CommentFormAttachButtons
CommentFormAttaches
CommentFormFormatButtons
CommentTextBlock
LocalCommentFormTextarea
containers
CoverBackdrop
FullWidth
Sticky
editors
AudioEditor
AudioGrid
EditorPanel
EditorUploadButton
EditorUploadCoverButton
ImageEditor
ImageGrid
SortableAudioGrid
TextEditor
VideoEditor
flow
Cell
FlowGrid
FlowHero
input
ArcProgress
Button
ButtonGroup
InputText
Textarea
main
GodRays
Notifications
UserButton
media/AudioPlayer
node
CommendDeleted
ImageSwitcher
NodeCommentForm
NodeComments
NodeImageSlideBlock
NodePanel
NodePanelInner
NodeRelatedItem
NodeTextBlock
NodeVideoBlock
notifications/NotificationMessage
profile
Message
MessageForm
ProfileDescription
tags
Tag
TagAutocomplete
TagInput
Tags
constants
containers
dialogs
BetterScrollDialog
EditorDialog
LoginDialog
LoginDialogButtons
Modal
PhotoSwipe
RestorePasswordDialog
RestoreRequestDialog
node
BorisLayout
NodeLayout
profile
ProfileAvatar
ProfileInfo
ProfileLayout
ProfileMessages
ProfilePageLeft
ProfileTabs
sidebars
ProfileSidebar
TagSidebar
redux

5
.env
View file

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

1
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,9 +31,9 @@ export const API = {
RELATED: (id: INode['id']) => `/node/${id}/related`,
UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`,
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_COMMENT: (id: INode['id'], comment_id: IComment['id']) =>
LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) =>
`/node/${id}/comment/${comment_id}/lock`,
SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`,
},

View file

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

View file

@ -1,3 +1,5 @@
import { INode } from '~/redux/types';
export const URLS = {
BASE: '/',
BORIS: '/boris',
@ -12,7 +14,7 @@ export const URLS = {
NOT_FOUND: '/lost',
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}`,
PROFILE: (username: string) => `/~${username}`,
PROFILE_PAGE: (username: string) => `/profile/${username}`,

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,10 @@ import { Button } from '~/components/input/Button';
import { Grid } from '~/components/containers/Grid';
import { Group } from '~/components/containers/Group';
import styles from './styles.module.scss';
import { ISocialProvider } from '~/redux/auth/types';
interface IProps {
openOauthWindow: (provider: string) => MouseEventHandler;
openOauthWindow: (provider: ISocialProvider) => MouseEventHandler;
}
const LoginDialogButtons: FC<IProps> = ({ openOauthWindow }) => (

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,7 @@
import React, { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import { selectNode } from '~/redux/node/selectors';
import { selectNode, selectNodeComments } from '~/redux/node/selectors';
import { selectUser } from '~/redux/auth/selectors';
import { connect } from 'react-redux';
import { useDispatch } from 'react-redux';
import { NodeComments } from '~/components/node/NodeComments';
import styles from './styles.module.scss';
import { Group } from '~/components/containers/Group';
@ -10,72 +9,50 @@ import boris from '~/sprites/boris_robot.svg';
import { NodeNoComments } from '~/components/node/NodeNoComments';
import { useRandomPhrase } from '~/constants/phrases';
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 { Card } from '~/components/containers/Card';
import { Footer } from '~/components/main/Footer';
import { Sticky } from '~/components/containers/Sticky';
import { selectBorisStats } from '~/redux/boris/selectors';
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 => ({
node: selectNode(state),
user: selectUser(state),
stats: selectBorisStats(state),
});
type IProps = {};
const mapDispatchToProps = {
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 BorisLayout: FC<IProps> = () => {
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(() => {
const last_comment = comments[0];
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;
authSetUser({ last_seen_boris: last_comment.created_at });
}, [comments, last_seen_boris]);
dispatch(authSetUser({ last_seen_boris: last_comment.created_at }));
}, [user.last_seen_boris, dispatch, comments]);
useEffect(() => {
if (is_loading) return;
nodeLoadNode(id, 'DESC');
}, [nodeLoadNode, id]);
if (node.is_loading) return;
dispatch(nodeLoadNode(696, 'DESC'));
}, [dispatch]);
useEffect(() => {
borisLoadStats();
}, [borisLoadStats]);
dispatch(borisLoadStats());
}, [dispatch]);
return (
<div className={styles.wrap}>
@ -92,20 +69,15 @@ const BorisLayoutUnconnected: FC<IProps> = ({
<div className={styles.container}>
<Card className={styles.content}>
<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} />
) : (
<NodeComments
comments={comments}
comment_data={comment_data}
comment_count={comment_count}
count={node.comment_count}
user={user}
onDelete={nodeLockComment}
onEdit={nodeEditComment}
onLoadMore={nodeLoadMoreComments}
modalShowPhotoswipe={modalShowPhotoswipe}
order="ASC"
/>
)}
@ -139,6 +111,4 @@ const BorisLayoutUnconnected: FC<IProps> = ({
);
};
const BorisLayout = connect(mapStateToProps, mapDispatchToProps)(BorisLayoutUnconnected);
export { BorisLayout };

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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 { Group } from '~/components/containers/Group';
import { Placeholder } from '~/components/placeholders/Placeholder';
@ -14,7 +14,7 @@ interface IProps {
is_loading?: boolean;
is_own?: boolean;
setTab?: (tab: string) => void;
setTab?: (tab: IAuthState['profile']['tab']) => void;
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.name}>
{is_loading ? <Placeholder width="80%" /> : user.fullname || user.username}
{is_loading ? <Placeholder width="80%" /> : user?.fullname || user?.username}
</div>
<div className={styles.description}>
{is_loading ? <Placeholder /> : getPrettyDate(user.last_seen)}
{is_loading ? <Placeholder /> : getPrettyDate(user?.last_seen)}
</div>
</div>
</Group>
<ProfileTabs tab={tab} is_own={is_own} setTab={setTab} />
<ProfileTabs tab={tab} is_own={!!is_own} setTab={setTab} />
{content}
</div>

View file

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

View file

@ -31,7 +31,7 @@ const ProfileMessagesUnconnected: FC<IProps> = ({
messagesRefreshMessages,
}) => {
const wasAtBottom = useRef(true);
const [wrap, setWrap] = useState<HTMLDivElement>(null);
const [wrap, setWrap] = useState<HTMLDivElement | undefined>(undefined);
const [editingMessageId, setEditingMessageId] = useState(0);
const onEditMessage = useCallback((id: number) => setEditingMessageId(id), [setEditingMessageId]);
@ -95,31 +95,33 @@ const ProfileMessagesUnconnected: FC<IProps> = ({
if (!messages.messages.length || profile.is_loading)
return <NodeNoComments is_loading={messages.is_loading_messages || profile.is_loading} />;
return (
messages.messages.length > 0 && (
<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}
/>
))}
if (messages.messages.length <= 0) {
return null;
}
{!messages.is_loading_messages && messages.messages.length > 0 && (
<div className={styles.placeholder}>Когда-нибудь здесь будут еще сообщения</div>
)}
</div>
)
return (
<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 && (
<div className={styles.placeholder}>Когда-нибудь здесь будут еще сообщения</div>
)}
</div>
);
};

View file

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

View file

@ -1,38 +1,49 @@
import React, { FC } from 'react';
import React, { FC, useCallback } from 'react';
import styles from './styles.module.scss';
import classNames from 'classnames';
import { IAuthState } from '~/redux/auth/types';
interface IProps {
tab: string;
is_own: boolean;
setTab: (tab: string) => void;
setTab?: (tab: IAuthState['profile']['tab']) => void;
}
const ProfileTabs: FC<IProps> = ({ tab, is_own, setTab }) => (
<div className={styles.wrap}>
<div
className={classNames(styles.tab, { [styles.active]: tab === 'profile' })}
onClick={() => setTab('profile')}
>
Профиль
const ProfileTabs: FC<IProps> = ({ tab, is_own, setTab }) => {
const changeTab = useCallback(
(tab: IAuthState['profile']['tab']) => () => {
if (!setTab) return;
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
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 };

View file

@ -56,7 +56,7 @@ const ProfileSidebarUnconnected: FC<Props> = ({
</Switch>
<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} />
<Filler />
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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 {
authAttachSocial,
authDropSocial,
@ -48,49 +48,37 @@ import {
selectAuthRestore,
selectAuthUpdates,
selectAuthUser,
selectToken,
} from './selectors';
import { IResultWithStatus, OAUTH_EVENT_TYPES, Unwrap } from '../types';
import { IAuthState, IUser } from './types';
import { OAUTH_EVENT_TYPES, Unwrap } from '../types';
import { REHYDRATE, RehydrateAction } from 'redux-persist';
import { selectModal } from '~/redux/modal/selectors';
import { IModalState } from '~/redux/modal';
import { DIALOGS } from '~/redux/modal/constants';
import { ERRORS } from '~/constants/errors';
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> {
const access = yield select(selectToken);
const result = yield call(requestAction, { access, ...props });
if (result && result.status === 401) {
return { error: USER_ERRORS.UNAUTHORIZED, data: {} };
}
return result;
function* setTokenSaga({ token }: ReturnType<typeof authSetToken>) {
localStorage.setItem('token', token);
}
function* sendLoginRequestSaga({ username, password }: ReturnType<typeof userSendLoginRequest>) {
if (!username || !password) return;
const {
error,
data: { token, user },
}: IResultWithStatus<{ token: string; user: IUser }> = yield call(apiUserLogin, {
username,
password,
});
try {
const { token, user }: Unwrap<typeof apiUserLogin> = yield call(apiUserLogin, {
username,
password,
});
if (error) {
yield put(userSetLoginError(error));
return;
yield put(authSetToken(token));
yield put(authSetUser({ ...user, is_user: true }));
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() {
@ -98,23 +86,18 @@ function* refreshUser() {
if (!token) return;
const {
error,
data: { user },
}: IResultWithStatus<{ user: IUser }> = yield call(reqWrapper, apiAuthGetUser);
try {
const { user }: Unwrap<typeof apiAuthGetUser> = yield call(apiAuthGetUser);
if (error) {
yield put(authSetUser({ ...user, is_user: true }));
} catch (e) {
yield put(
authSetUser({
...EMPTY_USER,
is_user: false,
})
);
return;
}
yield put(authSetUser({ ...user, is_user: true }));
}
function* checkUserSaga({ key }: RehydrateAction) {
@ -126,44 +109,43 @@ function* gotPostMessageSaga({ token }: ReturnType<typeof gotAuthPostMessage>) {
yield put(authSetToken(token));
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));
}
function* logoutSaga() {
yield put(authSetToken(null));
yield put(authSetToken(''));
yield put(authSetUser({ ...EMPTY_USER }));
yield put(
authSetUpdates({
last: null,
last: '',
notifications: [],
})
);
}
function* loadProfile({ username }: ReturnType<typeof authLoadProfile>) {
function* loadProfile({ username }: ReturnType<typeof authLoadProfile>): SagaIterator<boolean> {
yield put(authSetProfile({ is_loading: true }));
const {
error,
data: { user },
} = yield call(reqWrapper, apiAuthGetUserProfile, { username });
try {
const { user }: Unwrap<typeof apiAuthGetUserProfile> = yield call(apiAuthGetUserProfile, {
username,
});
if (error || !user) {
yield put(authSetProfile({ is_loading: false, user }));
yield put(messagesSet({ messages: [] }));
return true;
} catch (error) {
return false;
}
yield put(authSetProfile({ is_loading: false, user }));
yield put(messagesSet({ messages: [] }));
return true;
}
function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenProfile>) {
yield put(modalShowDialog(DIALOGS.PROFILE));
yield put(authSetProfile({ tab }));
const success: boolean = yield call(loadProfile, authLoadProfile(username));
const success: Unwrap<typeof loadProfile> = yield call(loadProfile, authLoadProfile(username));
if (!success) {
return yield put(modalSetShown(false));
@ -171,42 +153,41 @@ function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenP
}
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 profile: IAuthState['profile'] = yield select(selectAuthProfile);
const { last, boris_commented_at }: IAuthState['updates'] = yield select(selectAuthUpdates);
const exclude_dialogs =
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 modal: ReturnType<typeof selectModal> = yield select(selectModal);
const profile: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile);
const { last, boris_commented_at }: ReturnType<typeof selectAuthUpdates> = yield select(
selectAuthUpdates
);
}
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) {
yield put(
authSetUpdates({
boris_commented_at: data.boris.commented_at,
})
);
}
const data: Unwrap<typeof apiAuthGetUpdates> = yield call(apiAuthGetUpdates, {
exclude_dialogs,
last: last || user.last_seen_messages,
});
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() {
@ -219,148 +200,137 @@ function* startPollingSaga() {
function* setLastSeenMessages({ last_seen_messages }: ReturnType<typeof authSetLastSeenMessages>) {
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>) {
const me = yield select(selectAuthUser);
function* patchUser(payload: ReturnType<typeof authPatchUser>) {
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) {
return yield put(authSetProfile({ patch_errors: data.errors }));
yield put(authSetUser({ ...me, ...user }));
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>) {
if (!field) return;
yield put(authSetRestore({ error: null, is_loading: true }));
const { error, data } = yield call(apiRequestRestoreCode, { field });
try {
yield put(authSetRestore({ error: '', is_loading: true }));
yield call(apiRequestRestoreCode, {
field,
});
if (data.error || error) {
return yield put(authSetRestore({ is_loading: false, error: data.error || error }));
yield put(authSetRestore({ is_loading: false, is_succesfull: true }));
} 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>) {
if (!code && !code.length) {
return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false }));
}
try {
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(
authSetRestore({ is_loading: false, error: data.error || error || ERRORS.CODE_IS_INVALID })
authSetRestore({ is_loading: false, error: error.message || ERRORS.CODE_IS_INVALID })
);
return yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD));
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>) {
if (!password) return;
try {
if (!password) return;
yield put(authSetRestore({ is_loading: true }));
const { code } = yield select(selectAuthRestore);
yield put(authSetRestore({ is_loading: true }));
const { code }: ReturnType<typeof selectAuthRestore> = yield select(selectAuthRestore);
if (!code) {
return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false }));
}
if (!code) {
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(
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() {
yield put(authSetSocials({ is_loading: true, error: '' }));
try {
const { data, error }: Unwrap<ReturnType<typeof apiGetSocials>> = yield call(
reqWrapper,
apiGetSocials,
{}
);
if (error) {
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() }));
yield put(authSetSocials({ is_loading: true, error: '' }));
const data: Unwrap<typeof apiGetSocials> = yield call(apiGetSocials);
yield put(authSetSocials({ accounts: data.accounts }));
} catch (error) {
yield put(authSetSocials({ error: error.message }));
} finally {
yield put(authSetSocials({ is_loading: false }));
}
}
// TODO: start from here
function* dropSocial({ provider, id }: ReturnType<typeof authDropSocial>) {
try {
yield put(authSetSocials({ error: '' }));
const { error }: Unwrap<ReturnType<typeof apiDropSocial>> = yield call(
reqWrapper,
apiDropSocial,
{ id, provider }
);
if (error) {
throw new Error(error);
}
yield call(apiDropSocial, {
id,
provider,
});
yield call(getSocials);
} catch (e) {
yield put(authSetSocials({ error: e.message }));
} catch (error) {
yield put(authSetSocials({ error: error.message }));
}
}
function* attachSocial({ token }: ReturnType<typeof authAttachSocial>) {
if (!token) return;
try {
if (!token) return;
yield put(authSetSocials({ error: '', is_loading: true }));
const { data, error }: Unwrap<ReturnType<typeof apiAttachSocial>> = yield call(
reqWrapper,
apiAttachSocial,
{ token }
);
if (error) {
throw new Error(error);
}
const data: Unwrap<typeof apiAttachSocial> = yield call(apiAttachSocial, {
token,
});
const {
socials: { accounts },
}: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile);
if (accounts.some(it => it.id === data.account.id && it.provider === data.account.provider)) {
yield put(authSetSocials({ is_loading: false }));
} else {
yield put(authSetSocials({ is_loading: false, accounts: [...accounts, data.account] }));
return;
}
yield put(authSetSocials({ accounts: [...accounts, data.account] }));
} 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 {
yield put(userSetLoginError(''));
const {
data,
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);
}
const data: Unwrap<typeof apiLoginWithSocial> = yield call(apiLoginWithSocial, {
token,
});
if (data.token) {
yield put(authSetToken(data.token));
@ -390,8 +348,21 @@ function* loginWithSocial({ token }: ReturnType<typeof authLoginWithSocial>) {
yield put(modalSetShown(false));
return;
}
} catch (e) {
yield put(userSetLoginError(e.message));
} catch (error) {
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 {
yield put(authSetRegisterSocial({ error: '' }));
const { token }: Unwrap<ReturnType<typeof selectAuthRegisterSocial>> = yield select(
const { token }: ReturnType<typeof selectAuthRegisterSocial> = yield select(
selectAuthRegisterSocial
);
const { data, error }: Unwrap<ReturnType<typeof apiLoginWithSocial>> = yield call(
apiLoginWithSocial,
{
token,
username,
password,
}
);
if (data?.errors) {
yield put(authSetRegisterSocialErrors(data.errors));
} else if (data?.error) {
throw new Error(error);
}
const data: Unwrap<typeof apiLoginWithSocial> = yield call(apiLoginWithSocial, {
token,
username,
password,
});
if (data.token) {
yield put(authSetToken(data.token));
@ -439,8 +401,18 @@ function* authRegisterSocial({ username, password }: ReturnType<typeof authSendR
yield put(modalSetShown(false));
return;
}
} catch (e) {
yield put(authSetRegisterSocial({ error: e.message }));
} catch (error) {
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(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.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga);
yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile);

View file

@ -5,7 +5,7 @@ export const selectUser = (state: IState) => state.auth.user;
export const selectToken = (state: IState) => state.auth.token;
export const selectAuthLogin = (state: IState) => state.auth.login;
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 selectAuthUpdates = (state: IState) => state.auth.updates;
export const selectAuthRestore = (state: IState) => state.auth.restore;

View file

@ -1,13 +1,18 @@
import { IResultWithStatus } from '~/redux/types';
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) {
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: 'Пользователь не найден' };
case status === 200:
return { status, data, error: null };
return { status, data, error: '' };
default:
return { status, data, error: error || 'Неизвестная ошибка' };

View file

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

View file

@ -1,13 +1,10 @@
import git from '~/stats/git.json';
import { 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 { 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>> =>
api
.get(API.BORIS.GET_BACKEND_STATS)
.then(resultMiddleware)
.catch(errorMiddleware);
export const getBorisBackendStats = () =>
api.get<IStatBackend>(API.BORIS.GET_BACKEND_STATS).then(cleanResult);

View file

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

View file

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

View file

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

View file

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

View file

@ -1,182 +1,188 @@
import { takeLatest, call, put, select, takeLeading, delay, race, take } from 'redux-saga/effects';
import { call, delay, put, race, select, take, takeLatest, takeLeading } from 'redux-saga/effects';
import { REHYDRATE } from 'redux-persist';
import { FLOW_ACTIONS } from './constants';
import { getNodeDiff } from '../node/api';
import {
flowSetNodes,
flowSetCellView,
flowSetHeroes,
flowSetRecent,
flowSetUpdated,
flowSetFlow,
flowChangeSearch,
flowSetCellView,
flowSetFlow,
flowSetHeroes,
flowSetNodes,
flowSetRecent,
flowSetSearch,
flowSetUpdated,
} from './actions';
import { IResultWithStatus, INode, Unwrap } from '../types';
import { selectFlowNodes, selectFlow } from './selectors';
import { reqWrapper } from '../auth/sagas';
import { postCellView, getSearchResults } from './api';
import { IFlowState } from './reducer';
import { Unwrap } from '../types';
import { selectFlow, selectFlowNodes } from './selectors';
import { getSearchResults, postCellView } from './api';
import { uniq } from 'ramda';
function hideLoader() {
document.getElementById('main_loader').style.display = 'none';
}
const loader = document.getElementById('main_loader');
function* onGetFlow() {
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 }));
if (!loader) {
return;
}
yield put(
flowSetSearch({
is_loading: false,
results: data.nodes,
total: data.total,
})
);
loader.style.display = 'none';
}
function* onGetFlow() {
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() {
yield put(
flowSetSearch({
is_loading_more: true,
})
);
try {
yield put(
flowSetSearch({
is_loading_more: true,
})
);
const { search }: ReturnType<typeof selectFlow> = yield select(selectFlow);
const { search }: ReturnType<typeof selectFlow> = yield select(selectFlow);
const {
result,
delay,
}: { result: Unwrap<ReturnType<typeof getSearchResults>>; delay: any } = yield race({
result: call(reqWrapper, getSearchResults, {
...search,
skip: search.results.length,
}),
delay: take(FLOW_ACTIONS.CHANGE_SEARCH),
});
const { result, delay }: { result: Unwrap<typeof getSearchResults>; delay: any } = yield race({
result: call(getSearchResults, {
...search,
skip: search.results.length,
}),
delay: take(FLOW_ACTIONS.CHANGE_SEARCH),
});
if (delay || result.error) {
return put(flowSetSearch({ is_loading_more: false }));
if (delay) {
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() {

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

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

View file

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

View file

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

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