mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 20:36:40 +07:00
Merge branch 'develop'
This commit is contained in:
commit
4f6476666f
119 changed files with 1948 additions and 1783 deletions
|
@ -44,7 +44,7 @@
|
|||
"start": "craco start",
|
||||
"build": "craco build",
|
||||
"test": "craco test",
|
||||
"eject": "craco eject"
|
||||
"ts-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
|
|
@ -33,7 +33,8 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, modalSho
|
|||
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||
() =>
|
||||
reduce(
|
||||
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
||||
(group, file) =>
|
||||
file.type ? assocPath([file.type], append(file, group[file.type]), group) : group,
|
||||
{},
|
||||
comment.files
|
||||
),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -67,7 +67,13 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => {
|
|||
|
||||
<Group horizontal className={styles.buttons}>
|
||||
<CommentFormAttachButtons onUpload={uploader.uploadFiles} />
|
||||
<CommentFormFormatButtons element={textarea} handler={formik.handleChange('text')} />
|
||||
|
||||
{!!textarea && (
|
||||
<CommentFormFormatButtons
|
||||
element={textarea}
|
||||
handler={formik.handleChange('text')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && <LoaderCircle size={20} />}
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants';
|
|||
import { useFileUploaderContext } from '~/utils/hooks/fileUploader';
|
||||
|
||||
const CommentFormAttaches: FC = () => {
|
||||
const { files, pending, setFiles, uploadFiles } = useFileUploaderContext();
|
||||
const uploader = useFileUploaderContext();
|
||||
const { files, pending, setFiles, uploadFiles } = uploader!;
|
||||
|
||||
const images = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [
|
||||
files,
|
||||
|
@ -70,7 +71,7 @@ const CommentFormAttaches: FC = () => {
|
|||
);
|
||||
|
||||
const onAudioTitleChange = useCallback(
|
||||
(fileId: IFile['id'], title: IFile['metadata']['title']) => {
|
||||
(fileId: IFile['id'], title: string) => {
|
||||
setFiles(
|
||||
files.map(file =>
|
||||
file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file
|
||||
|
@ -80,36 +81,36 @@ const CommentFormAttaches: FC = () => {
|
|||
[files, setFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
hasAttaches && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
if (!hasAttaches) return null;
|
||||
|
||||
{hasAudioAttaches && (
|
||||
<SortableAudioGrid
|
||||
items={audios}
|
||||
onDelete={onFileDelete}
|
||||
onTitleChange={onAudioTitleChange}
|
||||
onSortEnd={onAudioMove}
|
||||
axis="y"
|
||||
locked={pendingAudios}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import { ButtonGroup } from '~/components/input/ButtonGroup';
|
||||
import { Button } from '~/components/input/Button';
|
||||
import { useFormatWrapper } from '~/utils/hooks/useFormatWrapper';
|
||||
import { useFormatWrapper, wrapTextInsideInput } from '~/utils/hooks/useFormatWrapper';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps {
|
||||
|
@ -15,16 +15,57 @@ const CommentFormFormatButtons: FC<IProps> = ({ element, handler }) => {
|
|||
[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={wrap('**', '**')}
|
||||
onClick={wrapBold}
|
||||
iconLeft="bold"
|
||||
size="small"
|
||||
color="gray"
|
||||
iconOnly
|
||||
type="button"
|
||||
label="Жирный"
|
||||
label="Жирный Ctrl+B"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
@ -34,7 +75,7 @@ const CommentFormFormatButtons: FC<IProps> = ({ element, handler }) => {
|
|||
color="gray"
|
||||
iconOnly
|
||||
type="button"
|
||||
label="Наклонный"
|
||||
label="Наклонный Ctrl+I"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
|
|
@ -28,15 +28,4 @@
|
|||
:global(.green) {
|
||||
color: $wisegreen;
|
||||
}
|
||||
|
||||
//&:last-child {
|
||||
// p {
|
||||
// &::after {
|
||||
// content: '';
|
||||
// display: inline-flex;
|
||||
// height: 1em;
|
||||
// width: 150px;
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ const LocalCommentFormTextarea: FC<IProps> = ({ setRef }) => {
|
|||
|
||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
|
||||
({ ctrlKey, key }) => {
|
||||
if (!!ctrlKey && key === 'Enter') handleSubmit(null);
|
||||
if (ctrlKey && key === 'Enter') handleSubmit(undefined);
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -64,7 +64,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);
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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="Адрес видео" />
|
||||
|
|
|
@ -119,7 +119,7 @@ const Cell: FC<IProps> = ({
|
|||
}
|
||||
}, [title]);
|
||||
|
||||
const cellText = useMemo(() => formatCellText(text), [text]);
|
||||
const cellText = useMemo(() => formatCellText(text || ''), [text]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.cell, styles[(flow && flow.display) || 'single'])} ref={ref}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -50,7 +50,7 @@ const Button: FC<IButtonProps> = memo(
|
|||
ref,
|
||||
...props
|
||||
}) => {
|
||||
const tooltip = useRef<HTMLSpanElement>();
|
||||
const tooltip = useRef<HTMLSpanElement | null>(null);
|
||||
const pop = usePopper(tooltip?.current?.parentElement, tooltip.current, {
|
||||
placement: 'top',
|
||||
modifiers: [
|
||||
|
|
|
@ -4,6 +4,7 @@ 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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -14,7 +14,7 @@ import { modalShowPhotoswipe } from '~/redux/modal/actions';
|
|||
import { useDispatch } from 'react-redux';
|
||||
|
||||
interface IProps {
|
||||
comments?: IComment[];
|
||||
comments: IComment[];
|
||||
count: INodeState['comment_count'];
|
||||
user: IUser;
|
||||
order?: 'ASC' | 'DESC';
|
||||
|
|
|
@ -36,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]);
|
||||
|
||||
|
@ -221,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);
|
||||
},
|
||||
|
@ -266,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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,7 +9,7 @@ import markdown from '~/styles/common/markdown.module.scss';
|
|||
interface IProps extends INodeComponentProps {}
|
||||
|
||||
const NodeTextBlock: FC<IProps> = ({ node }) => {
|
||||
const content = useMemo(() => formatTextParagraphs(path(['blocks', 0, 'text'], node)), [
|
||||
const content = useMemo(() => formatTextParagraphs(path(['blocks', 0, 'text'], node) || ''), [
|
||||
node.blocks,
|
||||
]);
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 '';
|
||||
};
|
||||
|
|
|
@ -87,7 +87,10 @@ const TagAutocompleteUnconnected: FC<Props> = ({
|
|||
|
||||
useEffect(() => {
|
||||
tagSetAutocomplete({ options: [] });
|
||||
return () => tagSetAutocomplete({ options: [] });
|
||||
|
||||
return () => {
|
||||
tagSetAutocomplete({ options: [] });
|
||||
};
|
||||
}, [tagSetAutocomplete]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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`,
|
||||
},
|
||||
|
|
|
@ -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]: 'Подключение не удалось',
|
||||
};
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
@ -93,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}>
|
||||
|
@ -107,7 +125,7 @@ const EditorDialogUnconnected: FC<IProps> = ({
|
|||
onClose={onRequestClose}
|
||||
>
|
||||
<div className={styles.editor}>
|
||||
{createElement(NODE_EDITORS[type], {
|
||||
{createElement(component, {
|
||||
data,
|
||||
setData,
|
||||
temp,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FC, FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import React, { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { 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)),
|
||||
|
@ -80,7 +82,7 @@ const LoginDialogUnconnected: FC<IProps> = ({
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) userSetLoginError(null);
|
||||
if (error) userSetLoginError('');
|
||||
}, [username, password]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -90,12 +92,14 @@ 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} />}
|
||||
|
|
|
@ -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 }) => (
|
||||
|
|
|
@ -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 };
|
||||
|
||||
|
|
|
@ -78,7 +78,9 @@ const PhotoSwipeUnconnected: FC<Props> = ({ photoswipe, modalSetShown }) => {
|
|||
|
||||
useEffect(() => {
|
||||
window.location.hash = 'preview';
|
||||
return () => (window.location.hash = '');
|
||||
return () => {
|
||||
window.location.hash = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FC, useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { IDialogProps } from '~/redux/types';
|
||||
import { 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}>
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ const BorisLayout: FC<IProps> = () => {
|
|||
|
||||
if (
|
||||
user.last_seen_boris &&
|
||||
last_comment.created_at &&
|
||||
!isBefore(new Date(user.last_seen_boris), new Date(last_comment.created_at))
|
||||
)
|
||||
return;
|
||||
|
|
|
@ -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';
|
||||
|
@ -71,9 +76,6 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
|||
nodeStar,
|
||||
nodeLock,
|
||||
nodeSetCoverImage,
|
||||
nodeLockComment,
|
||||
nodeEditComment,
|
||||
nodeLoadMoreComments,
|
||||
modalShowPhotoswipe,
|
||||
}) => {
|
||||
const [layout, setLayout] = useState({});
|
||||
|
@ -84,7 +86,6 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
|||
comments = [],
|
||||
current: node,
|
||||
related,
|
||||
comment_data,
|
||||
comment_count,
|
||||
} = useShallowSelect(selectNode);
|
||||
const updateLayout = useCallback(() => setLayout({}), []);
|
||||
|
@ -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(
|
||||
|
@ -208,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>
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 || 'Неизвестная ошибка' };
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
10
src/redux/flow/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { INode } from '~/redux/types';
|
||||
|
||||
export type GetSearchResultsRequest = {
|
||||
text: string;
|
||||
skip?: number;
|
||||
};
|
||||
export type GetSearchResultsResult = {
|
||||
nodes: INode[];
|
||||
total: number;
|
||||
};
|
|
@ -1,48 +1,29 @@
|
|||
import { IMessage, IResultWithStatus } from '~/redux/types';
|
||||
import { api, 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);
|
||||
|
|
|
@ -12,7 +12,7 @@ export interface IMessagesState {
|
|||
const INITIAL_STATE: IMessagesState = {
|
||||
is_loading_messages: true,
|
||||
is_sending_messages: false,
|
||||
error: null,
|
||||
error: '',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
|
|
|
@ -5,14 +5,9 @@ import {
|
|||
selectAuthProfileUsername,
|
||||
selectAuthUpdates,
|
||||
} from '~/redux/auth/selectors';
|
||||
import {
|
||||
apiMessagesDeleteMessage,
|
||||
apiMessagesGetUserMessages,
|
||||
apiMessagesSendMessage,
|
||||
} from '~/redux/messages/api';
|
||||
import { apiDeleteMessage, apiGetUserMessages, apiSendMessage } from '~/redux/messages/api';
|
||||
import { ERRORS } from '~/constants/errors';
|
||||
import { IMessageNotification, Unwrap } from '~/redux/types';
|
||||
import { reqWrapper } from '~/redux/auth/sagas';
|
||||
import {
|
||||
messagesDeleteMessage,
|
||||
messagesGetMessages,
|
||||
|
@ -25,191 +20,188 @@ import { selectMessages } from '~/redux/messages/selectors';
|
|||
import { sortCreatedAtDesc } from '~/utils/date';
|
||||
|
||||
function* getMessages({ username }: ReturnType<typeof messagesGetMessages>) {
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
try {
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_loading_messages: true,
|
||||
messages:
|
||||
messages &&
|
||||
messages.length > 0 &&
|
||||
(messages[0].to.username === username || messages[0].from.username === username)
|
||||
? messages
|
||||
: [],
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
error,
|
||||
data,
|
||||
}: Unwrap<ReturnType<typeof apiMessagesGetUserMessages>> = yield call(
|
||||
reqWrapper,
|
||||
apiMessagesGetUserMessages,
|
||||
{ username }
|
||||
);
|
||||
|
||||
if (error || !data.messages) {
|
||||
return yield put(
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_loading_messages: false,
|
||||
error: ERRORS.EMPTY_RESPONSE,
|
||||
is_loading_messages: true,
|
||||
messages:
|
||||
messages &&
|
||||
messages.length > 0 &&
|
||||
(messages[0].to.username === username || messages[0].from.username === username)
|
||||
? messages
|
||||
: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
yield put(messagesSet({ is_loading_messages: false, messages: data.messages }));
|
||||
const data: Unwrap<typeof apiGetUserMessages> = yield call(apiGetUserMessages, {
|
||||
username,
|
||||
});
|
||||
|
||||
const { notifications }: ReturnType<typeof selectAuthUpdates> = yield select(selectAuthUpdates);
|
||||
yield put(messagesSet({ is_loading_messages: false, messages: data.messages }));
|
||||
|
||||
// clear viewed message from notifcation list
|
||||
const filtered = notifications.filter(
|
||||
notification =>
|
||||
notification.type !== 'message' ||
|
||||
(notification as IMessageNotification).content.from.username !== username
|
||||
);
|
||||
const { notifications }: ReturnType<typeof selectAuthUpdates> = yield select(selectAuthUpdates);
|
||||
|
||||
if (filtered.length !== notifications.length) {
|
||||
yield put(authSetUpdates({ notifications: filtered }));
|
||||
// clear viewed message from notifcation list
|
||||
const filtered = notifications.filter(
|
||||
notification =>
|
||||
notification.type !== 'message' ||
|
||||
(notification as IMessageNotification)?.content?.from?.username !== username
|
||||
);
|
||||
|
||||
if (filtered.length !== notifications.length) {
|
||||
yield put(authSetUpdates({ notifications: filtered }));
|
||||
}
|
||||
} catch (error) {
|
||||
messagesSet({
|
||||
error: error.message || ERRORS.EMPTY_RESPONSE,
|
||||
});
|
||||
} finally {
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_loading_messages: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function* sendMessage({ message, onSuccess }: ReturnType<typeof messagesSendMessage>) {
|
||||
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
try {
|
||||
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
|
||||
if (!username) return;
|
||||
if (!username) return;
|
||||
|
||||
yield put(messagesSet({ is_sending_messages: true, error: null }));
|
||||
yield put(messagesSet({ is_sending_messages: true, error: '' }));
|
||||
|
||||
const { error, data }: Unwrap<ReturnType<typeof apiMessagesSendMessage>> = yield call(
|
||||
reqWrapper,
|
||||
apiMessagesSendMessage,
|
||||
{
|
||||
const data: Unwrap<typeof apiSendMessage> = yield call(apiSendMessage, {
|
||||
username,
|
||||
message,
|
||||
});
|
||||
|
||||
const { user }: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile);
|
||||
|
||||
if (user?.username !== username) {
|
||||
return yield put(messagesSet({ is_sending_messages: false }));
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !data.message) {
|
||||
return yield put(
|
||||
messagesSet({
|
||||
is_sending_messages: false,
|
||||
error: error || ERRORS.EMPTY_RESPONSE,
|
||||
})
|
||||
);
|
||||
}
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
|
||||
const { user }: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile);
|
||||
if (message.id && message.id > 0) {
|
||||
// modified
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_sending_messages: false,
|
||||
messages: messages.map(item => (item.id === message.id ? data.message : item)),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// created
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_sending_messages: false,
|
||||
messages: [data.message, ...messages],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (user.username !== username) {
|
||||
return yield put(messagesSet({ is_sending_messages: false }));
|
||||
}
|
||||
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
|
||||
if (message.id > 0) {
|
||||
// modified
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
messagesSet({
|
||||
error: error.message || ERRORS.EMPTY_RESPONSE,
|
||||
});
|
||||
} finally {
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_sending_messages: false,
|
||||
messages: messages.map(item => (item.id === message.id ? data.message : item)),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// created
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_sending_messages: false,
|
||||
messages: [data.message, ...messages],
|
||||
is_loading_messages: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
function* deleteMessage({ id, is_locked }: ReturnType<typeof messagesDeleteMessage>) {
|
||||
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
try {
|
||||
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
|
||||
if (!username) return;
|
||||
if (!username) return;
|
||||
|
||||
yield put(messagesSet({ is_sending_messages: true, error: null }));
|
||||
yield put(messagesSet({ is_sending_messages: true, error: '' }));
|
||||
|
||||
const { error, data }: Unwrap<ReturnType<typeof apiMessagesDeleteMessage>> = yield call(
|
||||
reqWrapper,
|
||||
apiMessagesDeleteMessage,
|
||||
{
|
||||
const data: Unwrap<typeof apiDeleteMessage> = yield call(apiDeleteMessage, {
|
||||
username,
|
||||
id,
|
||||
is_locked,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (error || !data.message) {
|
||||
return yield put(
|
||||
const currentUsername: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
|
||||
if (currentUsername !== username) {
|
||||
return yield put(messagesSet({ is_sending_messages: false }));
|
||||
}
|
||||
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_sending_messages: false,
|
||||
messages: messages.map(item => (item.id === id ? data.message : item)),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
messagesSet({
|
||||
error: error.message || ERRORS.EMPTY_RESPONSE,
|
||||
});
|
||||
} finally {
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_loading_messages: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const currentUsername: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
|
||||
if (currentUsername !== username) {
|
||||
return yield put(messagesSet({ is_sending_messages: false }));
|
||||
}
|
||||
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
|
||||
yield put(
|
||||
messagesSet({
|
||||
is_sending_messages: false,
|
||||
messages: messages.map(item => (item.id === id ? data.message : item)),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function* refreshMessages({}: ReturnType<typeof messagesRefreshMessages>) {
|
||||
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
try {
|
||||
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
|
||||
selectAuthProfileUsername
|
||||
);
|
||||
|
||||
if (!username) return;
|
||||
if (!username) return;
|
||||
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
|
||||
|
||||
yield put(messagesSet({ is_loading_messages: true }));
|
||||
yield put(messagesSet({ is_loading_messages: true }));
|
||||
|
||||
const after = messages.length > 0 ? messages[0].created_at : undefined;
|
||||
const after = messages.length > 0 ? messages[0].created_at : undefined;
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
}: Unwrap<ReturnType<typeof apiMessagesGetUserMessages>> = yield call(
|
||||
reqWrapper,
|
||||
apiMessagesGetUserMessages,
|
||||
{ username, after }
|
||||
);
|
||||
const data: Unwrap<typeof apiGetUserMessages> = yield call(apiGetUserMessages, {
|
||||
username,
|
||||
after,
|
||||
});
|
||||
|
||||
yield put(messagesSet({ is_loading_messages: false }));
|
||||
yield put(messagesSet({ is_loading_messages: false }));
|
||||
|
||||
if (error) {
|
||||
return yield put(
|
||||
if (!data.messages || !data.messages.length) return;
|
||||
|
||||
const newMessages = [...data.messages, ...messages].sort(sortCreatedAtDesc);
|
||||
yield put(messagesSet({ messages: newMessages }));
|
||||
} catch (error) {
|
||||
messagesSet({
|
||||
error: error.message || ERRORS.EMPTY_RESPONSE,
|
||||
});
|
||||
} finally {
|
||||
yield put(
|
||||
messagesSet({
|
||||
error: error || ERRORS.EMPTY_RESPONSE,
|
||||
is_loading_messages: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.messages || !data.messages.length) return;
|
||||
|
||||
const newMessages = [...data.messages, ...messages].sort(sortCreatedAtDesc);
|
||||
yield put(messagesSet({ messages: newMessages }));
|
||||
}
|
||||
|
||||
export default function*() {
|
||||
|
|
26
src/redux/messages/types.ts
Normal file
26
src/redux/messages/types.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { IMessage } from '~/redux/types';
|
||||
|
||||
export type ApiGetUserMessagesRequest = {
|
||||
username: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
};
|
||||
export type ApiGetUserMessagesResponse = { messages: IMessage[] };
|
||||
|
||||
export type ApiSendMessageRequest = {
|
||||
username: string;
|
||||
message: Partial<IMessage>;
|
||||
};
|
||||
export type ApiSendMessageResult = {
|
||||
message: IMessage;
|
||||
};
|
||||
|
||||
export type ApiDeleteMessageRequest = {
|
||||
username: string;
|
||||
id: number;
|
||||
is_locked: boolean;
|
||||
};
|
||||
|
||||
export type ApiDeleteMessageResult = {
|
||||
message: IMessage;
|
||||
};
|
|
@ -14,7 +14,7 @@ export interface IModalState {
|
|||
|
||||
const INITIAL_STATE: IModalState = {
|
||||
is_shown: false,
|
||||
dialog: null,
|
||||
dialog: '',
|
||||
photoswipe: {
|
||||
images: [],
|
||||
index: 0,
|
||||
|
|
|
@ -17,7 +17,7 @@ export const nodeSetSaveErrors = (errors: IValidationErrors) => ({
|
|||
type: NODE_ACTIONS.SET_SAVE_ERRORS,
|
||||
});
|
||||
|
||||
export const nodeGotoNode = (id: number, node_type: INode['type']) => ({
|
||||
export const nodeGotoNode = (id: INode['id'], node_type: INode['type']) => ({
|
||||
id,
|
||||
node_type,
|
||||
type: NODE_ACTIONS.GOTO_NODE,
|
||||
|
@ -55,11 +55,6 @@ export const nodePostLocalComment = (
|
|||
type: NODE_ACTIONS.POST_COMMENT,
|
||||
});
|
||||
|
||||
export const nodeCancelCommentEdit = (id: number) => ({
|
||||
id,
|
||||
type: NODE_ACTIONS.CANCEL_COMMENT_EDIT,
|
||||
});
|
||||
|
||||
export const nodeSetSendingComment = (is_sending_comment: boolean) => ({
|
||||
is_sending_comment,
|
||||
type: NODE_ACTIONS.SET_SENDING_COMMENT,
|
||||
|
|
|
@ -1,181 +1,102 @@
|
|||
import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api';
|
||||
import { INode, IResultWithStatus, IComment } from '../types';
|
||||
import { api, cleanResult, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api';
|
||||
import { IComment, INode, IResultWithStatus } from '../types';
|
||||
import { API } from '~/constants/api';
|
||||
import { nodeUpdateTags, nodeLike, nodeStar, nodeLock, nodeLockComment } from './actions';
|
||||
import { INodeState } from './reducer';
|
||||
import { COMMENTS_DISPLAY } from './constants';
|
||||
import {
|
||||
ApiGetNodeRelatedRequest,
|
||||
ApiGetNodeRelatedResult,
|
||||
ApiGetNodeRequest,
|
||||
ApiGetNodeResult,
|
||||
ApiLockCommentRequest,
|
||||
ApiLockcommentResult,
|
||||
ApiLockNodeRequest,
|
||||
ApiLockNodeResult,
|
||||
ApiPostCommentRequest,
|
||||
ApiPostCommentResult,
|
||||
ApiPostNodeHeroicRequest,
|
||||
ApiPostNodeHeroicResponse,
|
||||
ApiPostNodeLikeRequest,
|
||||
ApiPostNodeLikeResult,
|
||||
ApiPostNodeTagsRequest,
|
||||
ApiPostNodeTagsResult,
|
||||
GetNodeDiffRequest,
|
||||
GetNodeDiffResult,
|
||||
} from '~/redux/node/types';
|
||||
|
||||
export const postNode = ({
|
||||
access,
|
||||
node,
|
||||
}: {
|
||||
access: string;
|
||||
export type ApiPostNodeRequest = { node: INode };
|
||||
export type ApiPostNodeResult = {
|
||||
node: INode;
|
||||
}): Promise<IResultWithStatus<INode>> =>
|
||||
api
|
||||
.post(API.NODE.SAVE, node, configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
export const getNodes = ({
|
||||
from = null,
|
||||
access,
|
||||
}: {
|
||||
from?: string;
|
||||
access: string;
|
||||
}): Promise<IResultWithStatus<{ nodes: INode[] }>> =>
|
||||
api
|
||||
.get(API.NODE.GET, configWithToken(access, { params: { from } }))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
export type ApiGetNodeCommentsRequest = {
|
||||
id: number;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
};
|
||||
export type ApiGetNodeCommentsResponse = { comments: IComment[]; comment_count: number };
|
||||
|
||||
export const apiPostNode = ({ node }: ApiPostNodeRequest) =>
|
||||
api.post<ApiPostNodeResult>(API.NODE.SAVE, node).then(cleanResult);
|
||||
|
||||
export const getNodeDiff = ({
|
||||
start = null,
|
||||
end = null,
|
||||
start,
|
||||
end,
|
||||
take,
|
||||
with_heroes,
|
||||
with_updated,
|
||||
with_recent,
|
||||
with_valid,
|
||||
access,
|
||||
}: {
|
||||
start?: string;
|
||||
end?: string;
|
||||
take?: number;
|
||||
access: string;
|
||||
with_heroes: boolean;
|
||||
with_updated: boolean;
|
||||
with_recent: boolean;
|
||||
with_valid: boolean;
|
||||
}): Promise<IResultWithStatus<{ nodes: INode[] }>> =>
|
||||
}: GetNodeDiffRequest) =>
|
||||
api
|
||||
.get(
|
||||
API.NODE.GET_DIFF,
|
||||
configWithToken(access, {
|
||||
params: {
|
||||
start,
|
||||
end,
|
||||
take,
|
||||
with_heroes,
|
||||
with_updated,
|
||||
with_recent,
|
||||
with_valid,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.get<GetNodeDiffResult>(API.NODE.GET_DIFF, {
|
||||
params: {
|
||||
start,
|
||||
end,
|
||||
take,
|
||||
with_heroes,
|
||||
with_updated,
|
||||
with_recent,
|
||||
with_valid,
|
||||
},
|
||||
})
|
||||
.then(cleanResult);
|
||||
|
||||
export const getNode = ({
|
||||
id,
|
||||
access,
|
||||
}: {
|
||||
id: string | number;
|
||||
access: string;
|
||||
}): Promise<IResultWithStatus<{ nodes: INode[] }>> =>
|
||||
api
|
||||
.get(API.NODE.GET_NODE(id), configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
export const apiGetNode = ({ id }: ApiGetNodeRequest) =>
|
||||
api.get<ApiGetNodeResult>(API.NODE.GET_NODE(id)).then(cleanResult);
|
||||
|
||||
export const postNodeComment = ({
|
||||
id,
|
||||
data,
|
||||
access,
|
||||
}: {
|
||||
access: string;
|
||||
id: number;
|
||||
data: IComment;
|
||||
}): Promise<IResultWithStatus<{ comment: Comment }>> =>
|
||||
api
|
||||
.post(API.NODE.COMMENT(id), data, configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
export const apiPostComment = ({ id, data }: ApiPostCommentRequest) =>
|
||||
api.post<ApiPostCommentResult>(API.NODE.COMMENT(id), data).then(cleanResult);
|
||||
|
||||
export const getNodeComments = ({
|
||||
export const apiGetNodeComments = ({
|
||||
id,
|
||||
access,
|
||||
take = COMMENTS_DISPLAY,
|
||||
skip = 0,
|
||||
}: {
|
||||
id: number;
|
||||
access: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}): Promise<IResultWithStatus<{ comments: IComment[]; comment_count: number }>> =>
|
||||
}: ApiGetNodeCommentsRequest) =>
|
||||
api
|
||||
.get(API.NODE.COMMENT(id), configWithToken(access, { params: { take, skip } }))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.get<ApiGetNodeCommentsResponse>(API.NODE.COMMENT(id), { params: { take, skip } })
|
||||
.then(cleanResult);
|
||||
|
||||
export const getNodeRelated = ({
|
||||
id,
|
||||
access,
|
||||
}: {
|
||||
id: number;
|
||||
access: string;
|
||||
}): Promise<IResultWithStatus<{ related: INodeState['related'] }>> =>
|
||||
api
|
||||
.get(API.NODE.RELATED(id), configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
export const apiGetNodeRelated = ({ id }: ApiGetNodeRelatedRequest) =>
|
||||
api.get<ApiGetNodeRelatedResult>(API.NODE.RELATED(id)).then(cleanResult);
|
||||
|
||||
export const updateNodeTags = ({
|
||||
id,
|
||||
tags,
|
||||
access,
|
||||
}: ReturnType<typeof nodeUpdateTags> & { access: string }): Promise<IResultWithStatus<{
|
||||
node: INode;
|
||||
}>> =>
|
||||
export const apiPostNodeTags = ({ id, tags }: ApiPostNodeTagsRequest) =>
|
||||
api
|
||||
.post(API.NODE.UPDATE_TAGS(id), { tags }, configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.post<ApiPostNodeTagsResult>(API.NODE.UPDATE_TAGS(id), { tags })
|
||||
.then(cleanResult);
|
||||
|
||||
export const postNodeLike = ({
|
||||
id,
|
||||
access,
|
||||
}: ReturnType<typeof nodeLike> & { access: string }): Promise<IResultWithStatus<{
|
||||
is_liked: INode['is_liked'];
|
||||
}>> =>
|
||||
api
|
||||
.post(API.NODE.POST_LIKE(id), {}, configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
export const apiPostNodeLike = ({ id }: ApiPostNodeLikeRequest) =>
|
||||
api.post<ApiPostNodeLikeResult>(API.NODE.POST_LIKE(id)).then(cleanResult);
|
||||
|
||||
export const postNodeStar = ({
|
||||
id,
|
||||
access,
|
||||
}: ReturnType<typeof nodeStar> & { access: string }): Promise<IResultWithStatus<{
|
||||
is_liked: INode['is_liked'];
|
||||
}>> =>
|
||||
api
|
||||
.post(API.NODE.POST_STAR(id), {}, configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
export const apiPostNodeHeroic = ({ id }: ApiPostNodeHeroicRequest) =>
|
||||
api.post<ApiPostNodeHeroicResponse>(API.NODE.POST_HEROIC(id)).then(cleanResult);
|
||||
|
||||
export const postNodeLock = ({
|
||||
id,
|
||||
is_locked,
|
||||
access,
|
||||
}: ReturnType<typeof nodeLock> & { access: string }): Promise<IResultWithStatus<{
|
||||
deleted_at: INode['deleted_at'];
|
||||
}>> =>
|
||||
export const apiLockNode = ({ id, is_locked }: ApiLockNodeRequest) =>
|
||||
api
|
||||
.post(API.NODE.POST_LOCK(id), { is_locked }, configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.post<ApiLockNodeResult>(API.NODE.POST_LOCK(id), { is_locked })
|
||||
.then(cleanResult);
|
||||
|
||||
export const postNodeLockComment = ({
|
||||
id,
|
||||
is_locked,
|
||||
current,
|
||||
access,
|
||||
}: ReturnType<typeof nodeLockComment> & {
|
||||
access: string;
|
||||
current: INode['id'];
|
||||
}): Promise<IResultWithStatus<{ deleted_at: INode['deleted_at'] }>> =>
|
||||
export const apiLockComment = ({ id, is_locked, current }: ApiLockCommentRequest) =>
|
||||
api
|
||||
.post(API.NODE.POST_LOCK_COMMENT(current, id), { is_locked }, configWithToken(access))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.post<ApiLockcommentResult>(API.NODE.LOCK_COMMENT(current, id), { is_locked })
|
||||
.then(cleanResult);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FC } from 'react';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { IComment, INode, ValueOf } from '../types';
|
||||
import { NodeImageSlideBlock } from '~/components/node/NodeImageSlideBlock';
|
||||
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
||||
|
@ -13,7 +13,7 @@ import { EditorImageUploadButton } from '~/components/editors/EditorImageUploadB
|
|||
import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadButton';
|
||||
import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton';
|
||||
import { modalShowPhotoswipe } from '../modal/actions';
|
||||
import { IEditorComponentProps } from '~/redux/node/types';
|
||||
import { IEditorComponentProps, NodeEditorProps } from '~/redux/node/types';
|
||||
import { EditorFiller } from '~/components/editors/EditorFiller';
|
||||
|
||||
const prefix = 'NODE.';
|
||||
|
@ -29,7 +29,6 @@ export const NODE_ACTIONS = {
|
|||
LOCK: `${prefix}LOCK`,
|
||||
LOCK_COMMENT: `${prefix}LOCK_COMMENT`,
|
||||
EDIT_COMMENT: `${prefix}EDIT_COMMENT`,
|
||||
CANCEL_COMMENT_EDIT: `${prefix}CANCEL_COMMENT_EDIT`,
|
||||
CREATE: `${prefix}CREATE`,
|
||||
LOAD_MORE_COMMENTS: `${prefix}LOAD_MORE_COMMENTS`,
|
||||
|
||||
|
@ -51,15 +50,13 @@ export const NODE_ACTIONS = {
|
|||
};
|
||||
|
||||
export const EMPTY_NODE: INode = {
|
||||
id: null,
|
||||
|
||||
user: null,
|
||||
|
||||
id: 0,
|
||||
user: undefined,
|
||||
title: '',
|
||||
files: [],
|
||||
|
||||
cover: null,
|
||||
type: null,
|
||||
cover: undefined,
|
||||
type: undefined,
|
||||
|
||||
blocks: [],
|
||||
tags: [],
|
||||
|
@ -103,13 +100,16 @@ export const NODE_INLINES: INodeComponents = {
|
|||
};
|
||||
|
||||
export const EMPTY_COMMENT: IComment = {
|
||||
id: null,
|
||||
id: 0,
|
||||
text: '',
|
||||
files: [],
|
||||
user: null,
|
||||
user: undefined,
|
||||
};
|
||||
|
||||
export const NODE_EDITORS = {
|
||||
export const NODE_EDITORS: Record<
|
||||
typeof NODE_TYPES[keyof typeof NODE_TYPES],
|
||||
FC<NodeEditorProps>
|
||||
> = {
|
||||
[NODE_TYPES.IMAGE]: ImageEditor,
|
||||
[NODE_TYPES.TEXT]: TextEditor,
|
||||
[NODE_TYPES.VIDEO]: VideoEditor,
|
||||
|
|
|
@ -8,12 +8,12 @@ export type INodeState = Readonly<{
|
|||
current: INode;
|
||||
comments: IComment[];
|
||||
related: {
|
||||
albums: Record<string, Partial<INode[]>>;
|
||||
similar: Partial<INode[]>;
|
||||
albums: Record<string, INode[]>;
|
||||
similar: INode[];
|
||||
};
|
||||
comment_data: Record<number, IComment>;
|
||||
comment_count: number;
|
||||
current_cover_image: IFile;
|
||||
current_cover_image?: IFile;
|
||||
|
||||
error: string;
|
||||
errors: Record<string, string>;
|
||||
|
@ -38,14 +38,17 @@ const INITIAL_STATE: INodeState = {
|
|||
},
|
||||
comment_count: 0,
|
||||
comments: [],
|
||||
related: null,
|
||||
current_cover_image: null,
|
||||
related: {
|
||||
albums: {},
|
||||
similar: [],
|
||||
},
|
||||
current_cover_image: undefined,
|
||||
|
||||
is_loading: false,
|
||||
is_loading_comments: false,
|
||||
is_sending_comment: false,
|
||||
|
||||
error: null,
|
||||
error: '',
|
||||
errors: {},
|
||||
};
|
||||
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { all, call, delay, put, select, takeLatest, takeLeading } from 'redux-saga/effects';
|
||||
import { all, call, put, select, takeLatest, takeLeading } from 'redux-saga/effects';
|
||||
import { push } from 'connected-react-router';
|
||||
import { omit } from 'ramda';
|
||||
|
||||
import { COMMENTS_DISPLAY, EMPTY_COMMENT, EMPTY_NODE, NODE_ACTIONS, NODE_EDITOR_DATA } from './constants';
|
||||
import {
|
||||
nodeCancelCommentEdit,
|
||||
COMMENTS_DISPLAY,
|
||||
EMPTY_COMMENT,
|
||||
EMPTY_NODE,
|
||||
NODE_ACTIONS,
|
||||
NODE_EDITOR_DATA,
|
||||
} from './constants';
|
||||
import {
|
||||
nodeCreate,
|
||||
nodeEdit,
|
||||
nodeEditComment,
|
||||
nodeGotoNode,
|
||||
nodeLike,
|
||||
nodeLoadNode,
|
||||
|
@ -28,35 +31,34 @@ import {
|
|||
nodeUpdateTags,
|
||||
} from './actions';
|
||||
import {
|
||||
getNode,
|
||||
getNodeComments,
|
||||
getNodeRelated,
|
||||
postNode,
|
||||
postNodeComment,
|
||||
postNodeLike,
|
||||
postNodeLock,
|
||||
postNodeLockComment,
|
||||
postNodeStar,
|
||||
updateNodeTags,
|
||||
apiGetNode,
|
||||
apiGetNodeComments,
|
||||
apiGetNodeRelated,
|
||||
apiLockComment,
|
||||
apiLockNode,
|
||||
apiPostComment,
|
||||
apiPostNode,
|
||||
apiPostNodeHeroic,
|
||||
apiPostNodeLike,
|
||||
apiPostNodeTags,
|
||||
} from './api';
|
||||
import { reqWrapper } from '../auth/sagas';
|
||||
import { flowSetNodes, flowSetUpdated } from '../flow/actions';
|
||||
import { ERRORS } from '~/constants/errors';
|
||||
import { modalSetShown, modalShowDialog } from '../modal/actions';
|
||||
import { selectFlow, selectFlowNodes } from '../flow/selectors';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { selectNode } from './selectors';
|
||||
import { INode, IResultWithStatus, Unwrap } from '../types';
|
||||
import { Unwrap } from '../types';
|
||||
import { NODE_EDITOR_DIALOGS } from '~/constants/dialogs';
|
||||
import { DIALOGS } from '~/redux/modal/constants';
|
||||
import { INodeState } from './reducer';
|
||||
import { IFlowState } from '../flow/reducer';
|
||||
import { has } from 'ramda';
|
||||
|
||||
export function* updateNodeEverywhere(node) {
|
||||
const {
|
||||
current: { id },
|
||||
}: INodeState = yield select(selectNode);
|
||||
const flow_nodes: IFlowState['nodes'] = yield select(selectFlowNodes);
|
||||
}: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
const flow_nodes: ReturnType<typeof selectFlowNodes> = yield select(selectFlowNodes);
|
||||
|
||||
if (id === node.id) {
|
||||
yield put(nodeSetCurrent(node));
|
||||
|
@ -72,278 +74,282 @@ export function* updateNodeEverywhere(node) {
|
|||
}
|
||||
|
||||
function* onNodeSave({ node }: ReturnType<typeof nodeSave>) {
|
||||
yield put(nodeSetSaveErrors({}));
|
||||
try {
|
||||
yield put(nodeSetSaveErrors({}));
|
||||
|
||||
const {
|
||||
error,
|
||||
data: { errors, node: result },
|
||||
} = yield call(reqWrapper, postNode, { node });
|
||||
const { errors, node: result }: Unwrap<typeof apiPostNode> = yield call(apiPostNode, { node });
|
||||
|
||||
if (errors && Object.values(errors).length > 0) {
|
||||
return yield put(nodeSetSaveErrors(errors));
|
||||
if (errors && Object.values(errors).length > 0) {
|
||||
yield put(nodeSetSaveErrors(errors));
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes: ReturnType<typeof selectFlowNodes> = yield select(selectFlowNodes);
|
||||
const updated_flow_nodes = node.id
|
||||
? nodes.map(item => (item.id === result.id ? result : item))
|
||||
: [result, ...nodes];
|
||||
|
||||
yield put(flowSetNodes(updated_flow_nodes));
|
||||
|
||||
const { current } = yield select(selectNode);
|
||||
|
||||
if (node.id && current.id === result.id) {
|
||||
yield put(nodeSetCurrent(result));
|
||||
}
|
||||
|
||||
return yield put(modalSetShown(false));
|
||||
} catch (error) {
|
||||
yield put(nodeSetSaveErrors({ error: error.message || ERRORS.CANT_SAVE_NODE }));
|
||||
}
|
||||
|
||||
if (error || !result || !result.id) {
|
||||
return yield put(nodeSetSaveErrors({ error: error || ERRORS.CANT_SAVE_NODE }));
|
||||
}
|
||||
|
||||
const nodes = yield select(selectFlowNodes);
|
||||
const updated_flow_nodes = node.id
|
||||
? nodes.map(item => (item.id === result.id ? result : item))
|
||||
: [result, ...nodes];
|
||||
|
||||
yield put(flowSetNodes(updated_flow_nodes));
|
||||
|
||||
const { current } = yield select(selectNode);
|
||||
|
||||
if (node.id && current.id === result.id) {
|
||||
yield put(nodeSetCurrent(result));
|
||||
}
|
||||
|
||||
return yield put(modalSetShown(false));
|
||||
}
|
||||
|
||||
function* onNodeGoto({ id, node_type }: ReturnType<typeof nodeGotoNode>) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (node_type) yield put(nodeSetCurrent({ ...EMPTY_NODE, type: node_type }));
|
||||
|
||||
yield put(nodeLoadNode(id));
|
||||
yield put(nodeSetCommentData(0, { ...EMPTY_COMMENT }));
|
||||
yield put(nodeSetRelated(null));
|
||||
yield put(nodeSetRelated({ albums: {}, similar: [] }));
|
||||
}
|
||||
|
||||
function* onNodeLoadMoreComments() {
|
||||
const {
|
||||
current: { id },
|
||||
comments,
|
||||
}: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
try {
|
||||
const {
|
||||
current: { id },
|
||||
comments,
|
||||
}: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
const { data, error }: Unwrap<ReturnType<typeof getNodeComments>> = yield call(
|
||||
reqWrapper,
|
||||
getNodeComments,
|
||||
{
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Unwrap<typeof apiGetNodeComments> = yield call(apiGetNodeComments, {
|
||||
id,
|
||||
take: COMMENTS_DISPLAY,
|
||||
skip: comments.length,
|
||||
});
|
||||
|
||||
const current: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
if (!data || current.current.id != id) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
const current: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
if (!data || error || current.current.id != id) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield put(
|
||||
nodeSet({
|
||||
comments: [...comments, ...data.comments],
|
||||
comment_count: data.comment_count,
|
||||
})
|
||||
);
|
||||
yield put(
|
||||
nodeSet({
|
||||
comments: [...comments, ...data.comments],
|
||||
comment_count: data.comment_count,
|
||||
})
|
||||
);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
function* onNodeLoad({ id, order = 'ASC' }: ReturnType<typeof nodeLoadNode>) {
|
||||
yield put(nodeSetLoading(true));
|
||||
yield put(nodeSetLoadingComments(true));
|
||||
function* onNodeLoad({ id }: ReturnType<typeof nodeLoadNode>) {
|
||||
// Get node body
|
||||
try {
|
||||
yield put(nodeSetLoading(true));
|
||||
yield put(nodeSetLoadingComments(true));
|
||||
|
||||
const {
|
||||
data: { node, error },
|
||||
} = yield call(reqWrapper, getNode, { id });
|
||||
const { node }: Unwrap<typeof apiGetNode> = yield call(apiGetNode, { id });
|
||||
|
||||
if (error || !node || !node.id) {
|
||||
yield put(nodeSetCurrent(node));
|
||||
yield put(nodeSetLoading(false));
|
||||
} catch (error) {
|
||||
yield put(push(URLS.ERRORS.NOT_FOUND));
|
||||
yield put(nodeSetLoading(false));
|
||||
return;
|
||||
}
|
||||
|
||||
yield put(nodeSetCurrent(node));
|
||||
yield put(nodeSetLoading(false));
|
||||
// Comments and related
|
||||
try {
|
||||
const [{ comments, comment_count }, { related }]: [
|
||||
Unwrap<typeof apiGetNodeComments>,
|
||||
Unwrap<typeof apiGetNodeRelated>
|
||||
] = yield all([
|
||||
call(apiGetNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }),
|
||||
call(apiGetNodeRelated, { id }),
|
||||
]);
|
||||
|
||||
const {
|
||||
comments: {
|
||||
data: { comments, comment_count },
|
||||
},
|
||||
related: {
|
||||
data: { related },
|
||||
},
|
||||
} = yield all({
|
||||
comments: call(reqWrapper, getNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }),
|
||||
related: call(reqWrapper, getNodeRelated, { id }),
|
||||
});
|
||||
|
||||
yield put(
|
||||
nodeSet({
|
||||
comments,
|
||||
comment_count,
|
||||
related,
|
||||
is_loading_comments: false,
|
||||
comment_data: { 0: { ...EMPTY_COMMENT } },
|
||||
})
|
||||
);
|
||||
yield put(
|
||||
nodeSet({
|
||||
comments,
|
||||
comment_count,
|
||||
related,
|
||||
is_loading_comments: false,
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
|
||||
// Remove current node from recently updated
|
||||
const { updated } = yield select(selectFlow);
|
||||
|
||||
if (updated.some(item => item.id === id)) {
|
||||
yield put(flowSetUpdated(updated.filter(item => item.id !== id)));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function* onPostComment({ nodeId, comment, callback }: ReturnType<typeof nodePostLocalComment>) {
|
||||
const { data, error }: Unwrap<ReturnType<typeof postNodeComment>> = yield call(
|
||||
reqWrapper,
|
||||
postNodeComment,
|
||||
{
|
||||
try {
|
||||
const data: Unwrap<typeof apiPostComment> = yield call(apiPostComment, {
|
||||
data: comment,
|
||||
id: nodeId,
|
||||
});
|
||||
|
||||
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
if (current?.id === nodeId) {
|
||||
const { comments }: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
if (!comment.id) {
|
||||
yield put(nodeSetComments([data.comment, ...comments]));
|
||||
} else {
|
||||
yield put(
|
||||
nodeSet({
|
||||
comments: comments.map(item => (item.id === comment.id ? data.comment : item)),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
callback();
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !data.comment) {
|
||||
return callback(error);
|
||||
} catch (error) {
|
||||
return callback(error.message);
|
||||
}
|
||||
|
||||
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
if (current?.id === nodeId) {
|
||||
const { comments } = yield select(selectNode);
|
||||
|
||||
if (!comment.id) {
|
||||
yield put(nodeSetComments([data.comment, ...comments]));
|
||||
} else {
|
||||
yield put(
|
||||
nodeSet({
|
||||
comments: comments.map(item => (item.id === comment.id ? data.comment : item)),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function* onCancelCommentEdit({ id }: ReturnType<typeof nodeCancelCommentEdit>) {
|
||||
const { comment_data } = yield select(selectNode);
|
||||
|
||||
yield put(
|
||||
nodeSet({
|
||||
comment_data: omit([id.toString()], comment_data),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function* onUpdateTags({ id, tags }: ReturnType<typeof nodeUpdateTags>) {
|
||||
yield delay(100);
|
||||
|
||||
const {
|
||||
data: { node },
|
||||
}: IResultWithStatus<{ node: INode }> = yield call(reqWrapper, updateNodeTags, { id, tags });
|
||||
|
||||
const { current } = yield select(selectNode);
|
||||
|
||||
if (!node || !node.id || node.id !== current.id) return;
|
||||
|
||||
yield put(nodeSetTags(node.tags));
|
||||
try {
|
||||
const { node }: Unwrap<typeof apiPostNodeTags> = yield call(apiPostNodeTags, { id, tags });
|
||||
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
if (!node || !node.id || node.id !== current.id) return;
|
||||
yield put(nodeSetTags(node.tags));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function* onCreateSaga({ node_type: type }: ReturnType<typeof nodeCreate>) {
|
||||
if (!NODE_EDITOR_DIALOGS[type]) return;
|
||||
if (!type || !has(type, NODE_EDITOR_DIALOGS)) return;
|
||||
|
||||
yield put(nodeSetEditor({ ...EMPTY_NODE, ...(NODE_EDITOR_DATA[type] || {}), type }));
|
||||
yield put(modalShowDialog(NODE_EDITOR_DIALOGS[type]));
|
||||
}
|
||||
|
||||
function* onEditSaga({ id }: ReturnType<typeof nodeEdit>) {
|
||||
yield put(modalShowDialog(DIALOGS.LOADING));
|
||||
try {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
data: { node },
|
||||
error,
|
||||
} = yield call(reqWrapper, getNode, { id });
|
||||
yield put(modalShowDialog(DIALOGS.LOADING));
|
||||
|
||||
if (error || !node || !node.type || !NODE_EDITOR_DIALOGS[node.type])
|
||||
return yield put(modalSetShown(false));
|
||||
const { node }: Unwrap<typeof apiGetNode> = yield call(apiGetNode, { id });
|
||||
|
||||
yield put(nodeSetEditor(node));
|
||||
yield put(modalShowDialog(NODE_EDITOR_DIALOGS[node.type]));
|
||||
if (!node.type || !has(node.type, NODE_EDITOR_DIALOGS)) return;
|
||||
|
||||
return true;
|
||||
if (!NODE_EDITOR_DIALOGS[node?.type]) {
|
||||
throw new Error('Unknown node type');
|
||||
}
|
||||
|
||||
yield put(nodeSetEditor(node));
|
||||
yield put(modalShowDialog(NODE_EDITOR_DIALOGS[node.type]));
|
||||
} catch (error) {
|
||||
yield put(modalSetShown(false));
|
||||
}
|
||||
}
|
||||
|
||||
function* onLikeSaga({ id }: ReturnType<typeof nodeLike>) {
|
||||
const {
|
||||
current,
|
||||
current: { is_liked, like_count },
|
||||
} = yield select(selectNode);
|
||||
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
yield call(updateNodeEverywhere, {
|
||||
...current,
|
||||
is_liked: !is_liked,
|
||||
like_count: is_liked ? Math.max(like_count - 1, 0) : like_count + 1,
|
||||
});
|
||||
try {
|
||||
const count = current.like_count || 0;
|
||||
|
||||
const { data, error } = yield call(reqWrapper, postNodeLike, { id });
|
||||
yield call(updateNodeEverywhere, {
|
||||
...current,
|
||||
is_liked: !current.is_liked,
|
||||
like_count: current.is_liked ? Math.max(count - 1, 0) : count + 1,
|
||||
});
|
||||
|
||||
if (!error || data.is_liked === !is_liked) return; // ok and matches
|
||||
const data: Unwrap<typeof apiPostNodeLike> = yield call(apiPostNodeLike, { id });
|
||||
|
||||
yield call(updateNodeEverywhere, { ...current, is_liked, like_count });
|
||||
yield call(updateNodeEverywhere, {
|
||||
...current,
|
||||
is_liked: data.is_liked,
|
||||
like_count: data.is_liked ? count + 1 : Math.max(count - 1, 0),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function* onStarSaga({ id }: ReturnType<typeof nodeLike>) {
|
||||
const {
|
||||
current,
|
||||
current: { is_heroic },
|
||||
} = yield select(selectNode);
|
||||
try {
|
||||
const {
|
||||
current,
|
||||
current: { is_heroic },
|
||||
} = yield select(selectNode);
|
||||
|
||||
yield call(updateNodeEverywhere, { ...current, is_heroic: !is_heroic });
|
||||
yield call(updateNodeEverywhere, { ...current, is_heroic: !is_heroic });
|
||||
|
||||
const { data, error } = yield call(reqWrapper, postNodeStar, { id });
|
||||
const data: Unwrap<typeof apiPostNodeHeroic> = yield call(apiPostNodeHeroic, { id });
|
||||
|
||||
if (!error || data.is_heroic === !is_heroic) return; // ok and matches
|
||||
|
||||
yield call(updateNodeEverywhere, { ...current, is_heroic });
|
||||
yield call(updateNodeEverywhere, { ...current, is_heroic: data.is_heroic });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function* onLockSaga({ id, is_locked }: ReturnType<typeof nodeLock>) {
|
||||
const {
|
||||
current,
|
||||
current: { deleted_at },
|
||||
} = yield select(selectNode);
|
||||
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
yield call(updateNodeEverywhere, {
|
||||
...current,
|
||||
deleted_at: is_locked ? new Date().toISOString() : null,
|
||||
});
|
||||
try {
|
||||
yield call(updateNodeEverywhere, {
|
||||
...current,
|
||||
deleted_at: is_locked ? new Date().toISOString() : null,
|
||||
});
|
||||
|
||||
const { error } = yield call(reqWrapper, postNodeLock, { id, is_locked });
|
||||
const data: Unwrap<typeof apiLockNode> = yield call(apiLockNode, { id, is_locked });
|
||||
|
||||
if (error) return yield call(updateNodeEverywhere, { ...current, deleted_at });
|
||||
yield call(updateNodeEverywhere, {
|
||||
...current,
|
||||
deleted_at: data.deleted_at || undefined,
|
||||
});
|
||||
} catch {
|
||||
yield call(updateNodeEverywhere, { ...current, deleted_at: current.deleted_at });
|
||||
}
|
||||
}
|
||||
|
||||
function* onLockCommentSaga({ id, is_locked }: ReturnType<typeof nodeLockComment>) {
|
||||
const { current, comments } = yield select(selectNode);
|
||||
const { current, comments }: ReturnType<typeof selectNode> = yield select(selectNode);
|
||||
|
||||
yield put(
|
||||
nodeSetComments(
|
||||
comments.map(comment =>
|
||||
comment.id === id
|
||||
? { ...comment, deleted_at: is_locked ? new Date().toISOString() : null }
|
||||
: comment
|
||||
try {
|
||||
yield put(
|
||||
nodeSetComments(
|
||||
comments.map(comment =>
|
||||
comment.id === id
|
||||
? { ...comment, deleted_at: is_locked ? new Date().toISOString() : undefined }
|
||||
: comment
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
yield call(reqWrapper, postNodeLockComment, { current: current.id, id, is_locked });
|
||||
}
|
||||
const data: Unwrap<typeof apiLockComment> = yield call(apiLockComment, {
|
||||
current: current.id,
|
||||
id,
|
||||
is_locked,
|
||||
});
|
||||
|
||||
function* onEditCommentSaga({ id }: ReturnType<typeof nodeEditComment>) {
|
||||
const { comments } = yield select(selectNode);
|
||||
|
||||
const comment = comments.find(item => item.id === id);
|
||||
|
||||
if (!comment) return;
|
||||
|
||||
yield put(nodeSetCommentData(id, { ...EMPTY_COMMENT, ...comment }));
|
||||
yield put(
|
||||
nodeSetComments(
|
||||
comments.map(comment =>
|
||||
comment.id === id ? { ...comment, deleted_at: data.deleted_at || undefined } : comment
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
yield put(
|
||||
nodeSetComments(
|
||||
comments.map(comment =>
|
||||
comment.id === id ? { ...comment, deleted_at: current.deleted_at } : comment
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function* nodeSaga() {
|
||||
|
@ -351,7 +357,6 @@ export default function* nodeSaga() {
|
|||
yield takeLatest(NODE_ACTIONS.GOTO_NODE, onNodeGoto);
|
||||
yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad);
|
||||
yield takeLatest(NODE_ACTIONS.POST_COMMENT, onPostComment);
|
||||
yield takeLatest(NODE_ACTIONS.CANCEL_COMMENT_EDIT, onCancelCommentEdit);
|
||||
yield takeLatest(NODE_ACTIONS.UPDATE_TAGS, onUpdateTags);
|
||||
yield takeLatest(NODE_ACTIONS.CREATE, onCreateSaga);
|
||||
yield takeLatest(NODE_ACTIONS.EDIT, onEditSaga);
|
||||
|
@ -359,6 +364,5 @@ export default function* nodeSaga() {
|
|||
yield takeLatest(NODE_ACTIONS.STAR, onStarSaga);
|
||||
yield takeLatest(NODE_ACTIONS.LOCK, onLockSaga);
|
||||
yield takeLatest(NODE_ACTIONS.LOCK_COMMENT, onLockCommentSaga);
|
||||
yield takeLatest(NODE_ACTIONS.EDIT_COMMENT, onEditCommentSaga);
|
||||
yield takeLeading(NODE_ACTIONS.LOAD_MORE_COMMENTS, onNodeLoadMoreComments);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { INode } from '~/redux/types';
|
||||
import { IComment, INode } from '~/redux/types';
|
||||
import { INodeState } from '~/redux/node/reducer';
|
||||
|
||||
export interface IEditorComponentProps {
|
||||
data: INode;
|
||||
|
@ -6,3 +7,85 @@ export interface IEditorComponentProps {
|
|||
temp: string[];
|
||||
setTemp: (val: string[]) => void;
|
||||
}
|
||||
|
||||
export type GetNodeDiffRequest = {
|
||||
start?: string;
|
||||
end?: string;
|
||||
take?: number;
|
||||
with_heroes: boolean;
|
||||
with_updated: boolean;
|
||||
with_recent: boolean;
|
||||
with_valid: boolean;
|
||||
};
|
||||
|
||||
export type GetNodeDiffResult = {
|
||||
before?: INode[];
|
||||
after?: INode[];
|
||||
heroes?: INode[];
|
||||
recent?: INode[];
|
||||
updated?: INode[];
|
||||
valid: INode['id'][];
|
||||
};
|
||||
|
||||
export type PostCellViewRequest = {
|
||||
id: INode['id'];
|
||||
flow: INode['flow'];
|
||||
};
|
||||
export type PostCellViewResult = unknown; // TODO: update it with actual type
|
||||
|
||||
export type ApiGetNodeRequest = {
|
||||
id: string | number;
|
||||
};
|
||||
export type ApiGetNodeResult = { node: INode };
|
||||
|
||||
export type ApiGetNodeRelatedRequest = {
|
||||
id: INode['id'];
|
||||
};
|
||||
export type ApiGetNodeRelatedResult = {
|
||||
related: INodeState['related'];
|
||||
};
|
||||
|
||||
export type ApiPostCommentRequest = {
|
||||
id: INode['id'];
|
||||
data: IComment;
|
||||
};
|
||||
export type ApiPostCommentResult = {
|
||||
comment: IComment;
|
||||
};
|
||||
|
||||
export type ApiPostNodeTagsRequest = {
|
||||
id: INode['id'];
|
||||
tags: string[];
|
||||
};
|
||||
export type ApiPostNodeTagsResult = {
|
||||
node: INode;
|
||||
};
|
||||
|
||||
export type ApiPostNodeLikeRequest = { id: INode['id'] };
|
||||
export type ApiPostNodeLikeResult = { is_liked: boolean };
|
||||
|
||||
export type ApiPostNodeHeroicRequest = { id: INode['id'] };
|
||||
export type ApiPostNodeHeroicResponse = { is_heroic: boolean };
|
||||
|
||||
export type ApiLockNodeRequest = {
|
||||
id: INode['id'];
|
||||
is_locked: boolean;
|
||||
};
|
||||
export type ApiLockNodeResult = {
|
||||
deleted_at: string;
|
||||
};
|
||||
|
||||
export type ApiLockCommentRequest = {
|
||||
id: IComment['id'];
|
||||
current: INode['id'];
|
||||
is_locked: boolean;
|
||||
};
|
||||
export type ApiLockcommentResult = {
|
||||
deleted_at: string;
|
||||
};
|
||||
export type NodeEditorProps = {
|
||||
data: INode;
|
||||
setData: (val: INode) => void;
|
||||
temp: string[];
|
||||
setTemp: (val: string[]) => void;
|
||||
};
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
import { IResultWithStatus, IEmbed } from '../types';
|
||||
import { api, resultMiddleware, errorMiddleware } from '~/utils/api';
|
||||
import { api, cleanResult } from '~/utils/api';
|
||||
import { API } from '~/constants/api';
|
||||
import { ApiGetEmbedYoutubeResult } from '~/redux/player/types';
|
||||
|
||||
export const getEmbedYoutube = (
|
||||
ids: string[]
|
||||
): Promise<IResultWithStatus<{ items: Record<string, IEmbed> }>> =>
|
||||
export const apiGetEmbedYoutube = (ids: string[]) =>
|
||||
api
|
||||
.get(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } })
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.get<ApiGetEmbedYoutubeResult>(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } })
|
||||
.then(cleanResult);
|
||||
|
|
|
@ -5,13 +5,13 @@ import { IFile, IEmbed } from '../types';
|
|||
|
||||
export type IPlayerState = Readonly<{
|
||||
status: typeof PLAYER_STATES[keyof typeof PLAYER_STATES];
|
||||
file: IFile;
|
||||
file?: IFile;
|
||||
youtubes: Record<string, IEmbed>;
|
||||
}>;
|
||||
|
||||
const INITIAL_STATE: IPlayerState = {
|
||||
status: PLAYER_STATES.UNSET,
|
||||
file: null,
|
||||
file: undefined,
|
||||
youtubes: {},
|
||||
};
|
||||
|
||||
|
|
|
@ -10,11 +10,16 @@ import {
|
|||
import { Player } from '~/utils/player';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { Unwrap } from '../types';
|
||||
import { getEmbedYoutube } from './api';
|
||||
import { apiGetEmbedYoutube } from './api';
|
||||
import { selectPlayer } from './selectors';
|
||||
|
||||
function* setFileAndPlaySaga({ file }: ReturnType<typeof playerSetFile>) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield put(playerSetFile(file));
|
||||
|
||||
Player.set(getURL(file));
|
||||
Player.play();
|
||||
}
|
||||
|
@ -37,7 +42,7 @@ function seekSaga({ seek }: ReturnType<typeof playerSeek>) {
|
|||
|
||||
function* stoppedSaga() {
|
||||
yield put(playerSetStatus(PLAYER_STATES.UNSET));
|
||||
yield put(playerSetFile(null));
|
||||
yield put(playerSetFile(undefined));
|
||||
}
|
||||
|
||||
function* getYoutubeInfo() {
|
||||
|
@ -49,34 +54,38 @@ function* getYoutubeInfo() {
|
|||
ticker,
|
||||
}: { action: ReturnType<typeof playerGetYoutubeInfo>; ticker: any } = yield race({
|
||||
action: take(PLAYER_ACTIONS.GET_YOUTUBE_INFO),
|
||||
...(ids.length > 0 ? { ticker: delay(1000) } : {}),
|
||||
...(ids.length > 0 ? { ticker: delay(500) } : {}),
|
||||
});
|
||||
|
||||
if (action) {
|
||||
ids.push(action.url);
|
||||
}
|
||||
|
||||
if (ticker || ids.length > 25) {
|
||||
const result: Unwrap<ReturnType<typeof getEmbedYoutube>> = yield call(getEmbedYoutube, ids);
|
||||
if (!ticker && ids.length <= 25) {
|
||||
// Try to collect more items in next 500ms
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.error && result.data.items && Object.keys(result.data.items).length) {
|
||||
try {
|
||||
const data: Unwrap<typeof apiGetEmbedYoutube> = yield call(apiGetEmbedYoutube, ids);
|
||||
|
||||
if (data.items && Object.keys(data.items).length) {
|
||||
const { youtubes }: ReturnType<typeof selectPlayer> = yield select(selectPlayer);
|
||||
yield put(playerSet({ youtubes: { ...youtubes, ...result.data.items } }));
|
||||
yield put(playerSet({ youtubes: { ...youtubes, ...data.items } }));
|
||||
}
|
||||
|
||||
ids = [];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export default function* playerSaga() {
|
||||
yield fork(getYoutubeInfo);
|
||||
|
||||
yield takeLatest(PLAYER_ACTIONS.SET_FILE_AND_PLAY, setFileAndPlaySaga);
|
||||
yield takeLatest(PLAYER_ACTIONS.PAUSE, pauseSaga);
|
||||
yield takeLatest(PLAYER_ACTIONS.PLAY, playSaga);
|
||||
yield takeLatest(PLAYER_ACTIONS.SEEK, seekSaga);
|
||||
yield takeLatest(PLAYER_ACTIONS.STOP, stopSaga);
|
||||
yield takeLatest(PLAYER_ACTIONS.STOPPED, stoppedSaga);
|
||||
|
||||
yield fork(getYoutubeInfo);
|
||||
// yield takeEvery(PLAYER_ACTIONS.GET_YOUTUBE_INFO, getYoutubeInfo);
|
||||
}
|
||||
|
|
3
src/redux/player/types.ts
Normal file
3
src/redux/player/types.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { IEmbed } from '~/redux/types';
|
||||
|
||||
export type ApiGetEmbedYoutubeResult = { items: Record<string, IEmbed> };
|
|
@ -26,7 +26,7 @@ import playerSaga from '~/redux/player/sagas';
|
|||
import modal, { IModalState } from '~/redux/modal';
|
||||
import { modalSaga } from './modal/sagas';
|
||||
|
||||
import { authOpenProfile, gotAuthPostMessage } from './auth/actions';
|
||||
import { authLogout, authOpenProfile, gotAuthPostMessage } from './auth/actions';
|
||||
|
||||
import boris, { IBorisState } from './boris/reducer';
|
||||
import borisSaga from './boris/sagas';
|
||||
|
@ -36,6 +36,9 @@ import messagesSaga from './messages/sagas';
|
|||
|
||||
import tag, { ITagState } from './tag';
|
||||
import tagSaga from './tag/sagas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '~/utils/api';
|
||||
import { assocPath } from 'ramda';
|
||||
|
||||
const authPersistConfig: PersistConfig = {
|
||||
key: 'auth',
|
||||
|
@ -116,5 +119,27 @@ export function configureStore(): {
|
|||
|
||||
const persistor = persistStore(store);
|
||||
|
||||
// Pass token to axios
|
||||
api.interceptors.request.use(options => {
|
||||
const token = store.getState().auth.token;
|
||||
|
||||
if (!token) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return assocPath(['headers', 'authorization'], `Bearer ${token}`, options);
|
||||
});
|
||||
|
||||
// Logout on 401
|
||||
api.interceptors.response.use(undefined, (error: AxiosError<{ error: string }>) => {
|
||||
if (error.response?.status === 401) {
|
||||
store.dispatch(authLogout());
|
||||
}
|
||||
|
||||
error.message = error?.response?.data?.error || error?.response?.statusText || error.message;
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return { store, persistor };
|
||||
}
|
||||
|
|
|
@ -1,33 +1,18 @@
|
|||
import { INode, IResultWithStatus } from '~/redux/types';
|
||||
import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api';
|
||||
import { api, cleanResult } from '~/utils/api';
|
||||
import { API } from '~/constants/api';
|
||||
import {
|
||||
ApiGetNodesOfTagRequest,
|
||||
ApiGetNodesOfTagResult,
|
||||
ApiGetTagSuggestionsRequest,
|
||||
ApiGetTagSuggestionsResult,
|
||||
} from '~/redux/tag/types';
|
||||
|
||||
export const getTagNodes = ({
|
||||
access,
|
||||
tag,
|
||||
offset,
|
||||
limit,
|
||||
}: {
|
||||
access: string;
|
||||
tag: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}): Promise<IResultWithStatus<{ nodes: INode[]; count: number }>> =>
|
||||
export const apiGetNodesOfTag = ({ tag, offset, limit }: ApiGetNodesOfTagRequest) =>
|
||||
api
|
||||
.get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } }))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.get<ApiGetNodesOfTagResult>(API.TAG.NODES, { params: { name: tag, offset, limit } })
|
||||
.then(cleanResult);
|
||||
|
||||
export const getTagAutocomplete = ({
|
||||
search,
|
||||
exclude,
|
||||
access,
|
||||
}: {
|
||||
access: string;
|
||||
search: string;
|
||||
exclude: string[];
|
||||
}): Promise<IResultWithStatus<{ tags: string[] }>> =>
|
||||
export const apiGetTagSuggestions = ({ search, exclude }: ApiGetTagSuggestionsRequest) =>
|
||||
api
|
||||
.get(API.TAG.AUTOCOMPLETE, configWithToken(access, { params: { search, exclude } }))
|
||||
.then(resultMiddleware)
|
||||
.catch(errorMiddleware);
|
||||
.get<ApiGetTagSuggestionsResult>(API.TAG.AUTOCOMPLETE, { params: { search, exclude } })
|
||||
.then(cleanResult);
|
||||
|
|
|
@ -1,48 +1,48 @@
|
|||
import { TAG_ACTIONS } from '~/redux/tag/constants';
|
||||
import { call, delay, put, select, takeLatest } from 'redux-saga/effects';
|
||||
import { tagLoadAutocomplete, tagLoadNodes, tagSetAutocomplete, tagSetNodes, } from '~/redux/tag/actions';
|
||||
import { reqWrapper } from '~/redux/auth/sagas';
|
||||
import {
|
||||
tagLoadAutocomplete,
|
||||
tagLoadNodes,
|
||||
tagSetAutocomplete,
|
||||
tagSetNodes,
|
||||
} from '~/redux/tag/actions';
|
||||
import { selectTagNodes } from '~/redux/tag/selectors';
|
||||
import { getTagAutocomplete, getTagNodes } from '~/redux/tag/api';
|
||||
import { apiGetTagSuggestions, apiGetNodesOfTag } from '~/redux/tag/api';
|
||||
import { Unwrap } from '~/redux/types';
|
||||
|
||||
function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
|
||||
yield put(tagSetNodes({ isLoading: true }));
|
||||
yield put(tagSetNodes({ isLoading: true, list: [] }));
|
||||
|
||||
try {
|
||||
const { list }: ReturnType<typeof selectTagNodes> = yield select(selectTagNodes);
|
||||
const { data, error }: Unwrap<ReturnType<typeof getTagNodes>> = yield call(
|
||||
reqWrapper,
|
||||
getTagNodes,
|
||||
{ tag, limit: 18, offset: list.length }
|
||||
);
|
||||
const data: Unwrap<typeof apiGetNodesOfTag> = yield call(apiGetNodesOfTag, {
|
||||
tag,
|
||||
limit: 18,
|
||||
offset: list.length,
|
||||
});
|
||||
|
||||
if (error) throw new Error(error);
|
||||
|
||||
yield put(tagSetNodes({ isLoading: false, list: [...list, ...data.nodes], count: data.count }));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
yield put(tagSetNodes({ list: [...list, ...data.nodes], count: data.count }));
|
||||
} catch {
|
||||
} finally {
|
||||
yield put(tagSetNodes({ isLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function* loadAutocomplete({ search, exclude }: ReturnType<typeof tagLoadAutocomplete>) {
|
||||
if (search.length < 3) return;
|
||||
if (search.length < 2) return;
|
||||
|
||||
try {
|
||||
yield put(tagSetAutocomplete({ isLoading: true }));
|
||||
yield delay(100);
|
||||
yield delay(200);
|
||||
|
||||
const { data, error }: Unwrap<ReturnType<typeof getTagAutocomplete>> = yield call(
|
||||
reqWrapper,
|
||||
getTagAutocomplete,
|
||||
{ search, exclude }
|
||||
);
|
||||
const data: Unwrap<typeof apiGetTagSuggestions> = yield call(apiGetTagSuggestions, {
|
||||
search,
|
||||
exclude,
|
||||
});
|
||||
|
||||
if (error) throw new Error(error);
|
||||
|
||||
yield put(tagSetAutocomplete({ options: data.tags, isLoading: false }));
|
||||
} catch (e) {
|
||||
yield put(tagSetAutocomplete({ options: data.tags }));
|
||||
} catch {
|
||||
} finally {
|
||||
yield put(tagSetAutocomplete({ isLoading: false }));
|
||||
}
|
||||
}
|
||||
|
|
16
src/redux/tag/types.ts
Normal file
16
src/redux/tag/types.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { INode } from '~/redux/types';
|
||||
|
||||
export type ApiGetNodesOfTagRequest = {
|
||||
tag: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
export type ApiGetNodesOfTagResult = { nodes: INode[]; count: number };
|
||||
|
||||
export type ApiGetTagSuggestionsRequest = {
|
||||
search: string;
|
||||
exclude: string[];
|
||||
};
|
||||
export type ApiGetTagSuggestionsResult = {
|
||||
tags: string[];
|
||||
};
|
|
@ -71,7 +71,7 @@ export interface IFile {
|
|||
url: string;
|
||||
size: number;
|
||||
|
||||
type: IUploadType;
|
||||
type?: IUploadType;
|
||||
mime: string;
|
||||
metadata?: {
|
||||
id3title?: string;
|
||||
|
@ -92,7 +92,7 @@ export interface IFileWithUUID {
|
|||
file: File;
|
||||
subject?: string;
|
||||
target: string;
|
||||
type: string;
|
||||
type?: string;
|
||||
onSuccess?: (file: IFile) => void;
|
||||
onFail?: () => void;
|
||||
}
|
||||
|
@ -111,13 +111,13 @@ export type IBlock = IBlockText | IBlockEmbed;
|
|||
|
||||
export interface INode {
|
||||
id?: number;
|
||||
user: Partial<IUser>;
|
||||
user?: Partial<IUser>;
|
||||
|
||||
title: string;
|
||||
files: IFile[];
|
||||
|
||||
cover: IFile;
|
||||
type: string;
|
||||
cover?: IFile;
|
||||
type?: string;
|
||||
|
||||
blocks: IBlock[];
|
||||
thumbnail?: string;
|
||||
|
@ -143,7 +143,7 @@ export interface IComment {
|
|||
id: number;
|
||||
text: string;
|
||||
files: IFile[];
|
||||
user: IUser;
|
||||
user?: IUser;
|
||||
|
||||
created_at?: string;
|
||||
update_at?: string;
|
||||
|
@ -192,7 +192,13 @@ export type INodeNotification = {
|
|||
|
||||
export type INotification = IMessageNotification | ICommentNotification;
|
||||
|
||||
export type Unwrap<T> = T extends Promise<infer U> ? U : T;
|
||||
export type Unwrap<T> = T extends (...args: any) => Promise<any>
|
||||
? T extends (...args: any) => Promise<infer U>
|
||||
? U
|
||||
: T
|
||||
: T extends () => Iterator<any, infer U, any>
|
||||
? U
|
||||
: any;
|
||||
|
||||
export interface IEmbed {
|
||||
provider: string;
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue