mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 04:46:40 +07:00
Merge branch 'master' into develop
This commit is contained in:
commit
ab1bfd600a
52 changed files with 1264 additions and 226 deletions
|
@ -47,7 +47,7 @@
|
||||||
"resolve-url-loader": "^3.0.1",
|
"resolve-url-loader": "^3.0.1",
|
||||||
"style-loader": "^0.21.0",
|
"style-loader": "^0.21.0",
|
||||||
"ts-node": "^8.4.1",
|
"ts-node": "^8.4.1",
|
||||||
"typescript": "^3.6.4",
|
"typescript": "^3.7.2",
|
||||||
"uglifyjs-webpack-plugin": "^1.3.0",
|
"uglifyjs-webpack-plugin": "^1.3.0",
|
||||||
"webpack": "^4.41.2",
|
"webpack": "^4.41.2",
|
||||||
"webpack-cli": "^3.3.9",
|
"webpack-cli": "^3.3.9",
|
||||||
|
|
42
src/components/containers/InfiniteScroll/index.tsx
Normal file
42
src/components/containers/InfiniteScroll/index.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import React, { FC, HTMLAttributes, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
interface IProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
hasMore: boolean;
|
||||||
|
scrollReactPx?: number;
|
||||||
|
loadMore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteScroll: FC<IProps> = ({ children, hasMore, scrollReactPx, loadMore, ...props }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const onScrollEnd = useCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
if (!hasMore || !entries[0].isIntersecting) return;
|
||||||
|
loadMore();
|
||||||
|
},
|
||||||
|
[hasMore, loadMore]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(onScrollEnd, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '200px',
|
||||||
|
threshold: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(ref.current);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [ref.current, onScrollEnd]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...props}>
|
||||||
|
{children}
|
||||||
|
{hasMore && <div className={styles.more} ref={ref} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { InfiniteScroll };
|
|
@ -0,0 +1,2 @@
|
||||||
|
.more {
|
||||||
|
}
|
|
@ -3,4 +3,8 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
margin: 0 $gap $gap 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { FC, useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { getURL, formatCellText } from '~/utils/dom';
|
import { formatCellText, getURL } from '~/utils/dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
@ -109,6 +109,16 @@ const Cell: FC<IProps> = ({
|
||||||
return getURL({ url: thumbnail }, preset);
|
return getURL({ url: thumbnail }, preset);
|
||||||
}, [thumbnail, flow]);
|
}, [thumbnail, flow]);
|
||||||
|
|
||||||
|
const titleSize = useMemo(() => {
|
||||||
|
if (title.length > 100) {
|
||||||
|
return styles.small;
|
||||||
|
} else if (title.length > 64) {
|
||||||
|
return styles.medium;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.cell, styles[(flow && flow.display) || 'single'])} ref={ref}>
|
<div className={classNames(styles.cell, styles[(flow && flow.display) || 'single'])} ref={ref}>
|
||||||
{is_visible && (
|
{is_visible && (
|
||||||
|
@ -134,7 +144,7 @@ const Cell: FC<IProps> = ({
|
||||||
|
|
||||||
<Link className={classNames(styles.face)} to={`/post${id}`}>
|
<Link className={classNames(styles.face)} to={`/post${id}`}>
|
||||||
<div className={styles.face_content}>
|
<div className={styles.face_content}>
|
||||||
{!text && <div className={styles.title}>{title || '...'}</div>}
|
{!text && <div className={classNames(styles.title, titleSize)}>{title || '...'}</div>}
|
||||||
|
|
||||||
{!!text && !!thumbnail && (
|
{!!text && !!thumbnail && (
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
|
|
|
@ -95,10 +95,21 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
transition: opacity 0.5s, transform 1s;
|
transition: opacity 0.5s, transform 1s;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
@include clamp(8, 1.25em);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.medium{
|
||||||
|
@include clamp(6, 1.25em);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text_title {
|
.text_title {
|
||||||
margin-bottom: $gap / 2;
|
margin-bottom: $gap / 2;
|
||||||
|
@include clamp(3, 1.25em)
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal,
|
.horizontal,
|
||||||
|
|
|
@ -54,4 +54,7 @@
|
||||||
font: $font_12_regular;
|
font: $font_12_regular;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, ReactElement } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
title: string;
|
title: ReactElement | string;
|
||||||
items: Partial<INode>[];
|
items: Partial<INode>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,11 @@
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@include title_with_line();
|
@include title_with_line();
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
import React, { FC, memo } from 'react';
|
import React, { FC, memo } from 'react';
|
||||||
import { Tags } from '../Tags';
|
|
||||||
import { ITag } from '~/redux/types';
|
import { ITag } from '~/redux/types';
|
||||||
|
import { Tags } from '~/components/tags/Tags';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
is_editable?: boolean;
|
is_editable?: boolean;
|
||||||
tags: ITag[];
|
tags: ITag[];
|
||||||
onChange?: (tags: string[]) => void;
|
onChange?: (tags: string[]) => void;
|
||||||
|
onTagClick?: (tag: Partial<ITag>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange }) => (
|
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange, onTagClick }) => {
|
||||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
return (
|
||||||
));
|
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} onTagClick={onTagClick} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export { NodeTags };
|
export { NodeTags };
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import React, { FC, memo } from "react";
|
|
||||||
import { Tags } from "../Tags";
|
|
||||||
import { ITag } from "~/redux/types";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
is_editable?: boolean;
|
|
||||||
tags: ITag[];
|
|
||||||
onChange?: (tags: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodeTagsPlaceholder: FC<IProps> = memo(
|
|
||||||
({ is_editable, tags, onChange }) => (
|
|
||||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export { NodeTagsPlaceholder };
|
|
15
src/components/node/NodeTagsPlaceholder/index.tsx
Normal file
15
src/components/node/NodeTagsPlaceholder/index.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import React, { FC, memo } from 'react';
|
||||||
|
import { ITag } from '~/redux/types';
|
||||||
|
import { Tags } from '~/components/tags/Tags';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
is_editable?: boolean;
|
||||||
|
tags: ITag[];
|
||||||
|
onChange?: (tags: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeTagsPlaceholder: FC<IProps> = memo(({ is_editable, tags, onChange }) => (
|
||||||
|
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
||||||
|
));
|
||||||
|
|
||||||
|
export { NodeTagsPlaceholder };
|
|
@ -1,49 +0,0 @@
|
||||||
import React, { ChangeEventHandler, FC, FocusEventHandler, KeyboardEventHandler } from 'react';
|
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { ITag } from '~/redux/types';
|
|
||||||
import classNames = require('classnames');
|
|
||||||
|
|
||||||
const getTagFeature = (tag: Partial<ITag>) => {
|
|
||||||
if (tag.title.substr(0, 1) === '/') return 'green';
|
|
||||||
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
tag: Partial<ITag>;
|
|
||||||
|
|
||||||
is_hoverable?: boolean;
|
|
||||||
is_editing?: boolean;
|
|
||||||
|
|
||||||
onInput?: ChangeEventHandler<HTMLInputElement>;
|
|
||||||
onKeyUp?: KeyboardEventHandler;
|
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tag: FC<IProps> = ({ tag, is_hoverable, is_editing, onInput, onKeyUp, onBlur }) => (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.tag, getTagFeature(tag), {
|
|
||||||
is_hoverable,
|
|
||||||
is_editing,
|
|
||||||
input: !!onInput,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className={styles.hole} />
|
|
||||||
<div className={styles.title}>{tag.title}</div>
|
|
||||||
|
|
||||||
{onInput && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tag.title}
|
|
||||||
size={1}
|
|
||||||
placeholder="Добавить"
|
|
||||||
maxLength={24}
|
|
||||||
onChange={onInput}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onBlur={onBlur}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export { Tag };
|
|
|
@ -1,117 +0,0 @@
|
||||||
import React, {
|
|
||||||
FC,
|
|
||||||
HTMLAttributes,
|
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
KeyboardEvent,
|
|
||||||
ChangeEvent,
|
|
||||||
useRef,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
import { TagField } from '~/components/containers/TagField';
|
|
||||||
import { ITag } from '~/redux/types';
|
|
||||||
import { Tag } from '~/components/node/Tag';
|
|
||||||
import uniq from 'ramda/es/uniq';
|
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
tags: Partial<ITag>[];
|
|
||||||
is_editable?: boolean;
|
|
||||||
onTagsChange?: (tags: string[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, ...props }) => {
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [data, setData] = useState([]);
|
|
||||||
const timer = useRef(null);
|
|
||||||
|
|
||||||
const [catTags, ordinaryTags] = useMemo(
|
|
||||||
() =>
|
|
||||||
(tags || []).reduce(
|
|
||||||
(obj, tag) =>
|
|
||||||
tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]],
|
|
||||||
[[], []]
|
|
||||||
),
|
|
||||||
[tags]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onInput = useCallback(
|
|
||||||
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
clearTimeout(timer.current);
|
|
||||||
setInput(value);
|
|
||||||
},
|
|
||||||
[setInput, timer]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onKeyUp = useCallback(
|
|
||||||
({ key }: KeyboardEvent) => {
|
|
||||||
if (key === 'Backspace' && input === '' && data.length) {
|
|
||||||
setData(data.slice(0, data.length - 1));
|
|
||||||
setInput(data[data.length - 1].title);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'Enter' || key === ',' || key === 'Comma') {
|
|
||||||
setData(
|
|
||||||
uniq([
|
|
||||||
...data,
|
|
||||||
...input
|
|
||||||
.split(',')
|
|
||||||
.map((title: string) =>
|
|
||||||
title
|
|
||||||
.trim()
|
|
||||||
.substr(0, 32)
|
|
||||||
.toLowerCase()
|
|
||||||
)
|
|
||||||
.filter(el => el.length > 0)
|
|
||||||
.filter(el => !tags.some(tag => tag.title.trim() === el.trim()))
|
|
||||||
.map(title => ({
|
|
||||||
title,
|
|
||||||
})),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
setInput('');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[input, setInput, data, setData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = useCallback(() => {
|
|
||||||
const title = input && input.trim();
|
|
||||||
const items = (title ? [...data, { title }] : data)
|
|
||||||
.filter(tag => tag.title.length > 0)
|
|
||||||
.map(tag => ({
|
|
||||||
...tag,
|
|
||||||
title: tag.title.toLowerCase(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!items.length) return;
|
|
||||||
|
|
||||||
setData(items);
|
|
||||||
setInput('');
|
|
||||||
onTagsChange(uniq([...tags, ...items]).map(tag => tag.title));
|
|
||||||
}, [tags, data, onTagsChange, input, setInput]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));
|
|
||||||
}, [tags]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TagField {...props}>
|
|
||||||
{catTags.map(tag => (
|
|
||||||
<Tag key={tag.title} tag={tag} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{ordinaryTags.map(tag => (
|
|
||||||
<Tag key={tag.title} tag={tag} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{data.map(tag => (
|
|
||||||
<Tag key={tag.title} tag={tag} is_editing />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{is_editable && (
|
|
||||||
<Tag tag={{ title: input }} onInput={onInput} onKeyUp={onKeyUp} onBlur={onSubmit} />
|
|
||||||
)}
|
|
||||||
</TagField>
|
|
||||||
);
|
|
||||||
};
|
|
18
src/components/sidebar/TagSidebarList/index.tsx
Normal file
18
src/components/sidebar/TagSidebarList/index.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { FlowRecentItem } from '~/components/flow/FlowRecentItem';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
nodes: INode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagSidebarList: FC<IProps> = ({ nodes }) => (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{nodes.map(node => (
|
||||||
|
<FlowRecentItem node={node} key={node.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { TagSidebarList };
|
4
src/components/sidebar/TagSidebarList/styles.module.scss
Normal file
4
src/components/sidebar/TagSidebarList/styles.module.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
40
src/components/tags/Tag/index.tsx
Normal file
40
src/components/tags/Tag/index.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { FC, FocusEventHandler, useCallback } from 'react';
|
||||||
|
import { ITag } from '~/redux/types';
|
||||||
|
import { TagWrapper } from '~/components/tags/TagWrapper';
|
||||||
|
|
||||||
|
const getTagFeature = (tag: Partial<ITag>) => {
|
||||||
|
if (tag.title.substr(0, 1) === '/') return 'green';
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
tag: Partial<ITag>;
|
||||||
|
size?: 'normal' | 'big';
|
||||||
|
|
||||||
|
is_hoverable?: boolean;
|
||||||
|
is_editing?: boolean;
|
||||||
|
|
||||||
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
onClick?: (tag: Partial<ITag>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tag: FC<IProps> = ({ tag, is_hoverable, is_editing, size = 'normal', onBlur, onClick }) => {
|
||||||
|
const onClickHandler = useCallback(() => {
|
||||||
|
if (!onClick) return;
|
||||||
|
onClick(tag);
|
||||||
|
}, [tag, onClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagWrapper
|
||||||
|
feature={getTagFeature(tag)}
|
||||||
|
size={size}
|
||||||
|
is_hoverable={is_hoverable}
|
||||||
|
is_editing={is_editing}
|
||||||
|
onClick={onClick && onClickHandler}
|
||||||
|
title={tag.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Tag };
|
0
src/components/tags/Tag/styles.scss
Normal file
0
src/components/tags/Tag/styles.scss
Normal file
137
src/components/tags/TagAutocomplete/index.tsx
Normal file
137
src/components/tags/TagAutocomplete/index.tsx
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import * as TAG_ACTIONS from '~/redux/tag/actions';
|
||||||
|
import { selectTagAutocomplete } from '~/redux/tag/selectors';
|
||||||
|
import { separateTagOptions } from '~/utils/tag';
|
||||||
|
import { TagAutocompleteRow } from '~/components/tags/TagAutocompleteRow';
|
||||||
|
|
||||||
|
const mapStateToProps = selectTagAutocomplete;
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
tagSetAutocomplete: TAG_ACTIONS.tagSetAutocomplete,
|
||||||
|
tagLoadAutocomplete: TAG_ACTIONS.tagLoadAutocomplete,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps & {
|
||||||
|
exclude: string[];
|
||||||
|
input: HTMLInputElement;
|
||||||
|
onSelect: (val: string) => void;
|
||||||
|
search: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagAutocompleteUnconnected: FC<Props> = ({
|
||||||
|
exclude,
|
||||||
|
input,
|
||||||
|
onSelect,
|
||||||
|
search,
|
||||||
|
tagSetAutocomplete,
|
||||||
|
tagLoadAutocomplete,
|
||||||
|
options,
|
||||||
|
}) => {
|
||||||
|
const [top, setTop] = useState(false);
|
||||||
|
const [left, setLeft] = useState(false);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState(-1);
|
||||||
|
const [categories, tags] = useMemo(
|
||||||
|
() =>
|
||||||
|
separateTagOptions(options.filter(option => option !== search && !exclude.includes(option))),
|
||||||
|
[options, search, exclude]
|
||||||
|
);
|
||||||
|
const scroll = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
event => {
|
||||||
|
const all = [...categories, ...tags];
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected(selected < all.length - 1 ? selected + 1 : -1);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected(selected > -1 ? selected - 1 : all.length - 1);
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
onSelect(selected >= 0 ? all[selected] : search);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSelected, selected, categories, tags, onSelect, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
if (!scroll.current) return;
|
||||||
|
const { y, height, x, width } = scroll.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
const newTop = window.innerHeight - y - height <= (top ? 120 : 10);
|
||||||
|
if (top !== newTop) setTop(newTop);
|
||||||
|
|
||||||
|
const newLeft = x <= 0;
|
||||||
|
if (newLeft !== left) setLeft(newLeft);
|
||||||
|
}, [scroll.current, top, left]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
input.addEventListener('keydown', onKeyDown, false);
|
||||||
|
return () => input.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [input, onKeyDown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(-1);
|
||||||
|
tagLoadAutocomplete(search, exclude);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tagSetAutocomplete({ options: [] });
|
||||||
|
return () => tagSetAutocomplete({ options: [] });
|
||||||
|
}, [tagSetAutocomplete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scroll.current || !scroll.current?.children[selected + 1]) return;
|
||||||
|
const el = scroll.current?.children[selected + 1] as HTMLDivElement;
|
||||||
|
const { scrollTop, clientHeight } = scroll.current;
|
||||||
|
const { offsetTop } = el;
|
||||||
|
|
||||||
|
if (clientHeight - scrollTop + el.clientHeight < offsetTop || offsetTop < scrollTop) {
|
||||||
|
scroll.current.scrollTo(0, el.offsetTop - el.clientHeight);
|
||||||
|
}
|
||||||
|
}, [selected, scroll.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onScroll();
|
||||||
|
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
window.addEventListener('scroll', onScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
};
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.window, { [styles.top]: top, [styles.left]: left })}>
|
||||||
|
<div className={styles.scroll} ref={scroll}>
|
||||||
|
<TagAutocompleteRow selected={selected === -1} title={search} type="enter" />
|
||||||
|
|
||||||
|
{categories.map((item, i) => (
|
||||||
|
<TagAutocompleteRow selected={selected === i} title={item} type="right" key={item} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tags.map((item, i) => (
|
||||||
|
<TagAutocompleteRow
|
||||||
|
selected={selected === categories.length + i}
|
||||||
|
title={item}
|
||||||
|
type="tag"
|
||||||
|
key={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagAutocomplete = connect(mapStateToProps, mapDispatchToProps)(TagAutocompleteUnconnected);
|
||||||
|
|
||||||
|
export { TagAutocomplete };
|
37
src/components/tags/TagAutocomplete/styles.module.scss
Normal file
37
src/components/tags/TagAutocomplete/styles.module.scss
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
@keyframes appear {
|
||||||
|
0% { opacity: 0 }
|
||||||
|
100% { opacity: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
$row_height: 24px;
|
||||||
|
|
||||||
|
.window {
|
||||||
|
box-shadow: transparentize(white, 0.9) 0 0 0 1px, transparentize(black, 0.7) 4px 4px;
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: calc(100vw - 15px);
|
||||||
|
max-width: 300px;
|
||||||
|
background: darken($content_bg, 2%);
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: $tag_height + 4px 0 0;
|
||||||
|
animation: appear 0.25s forwards;
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
bottom: -2px;
|
||||||
|
top: auto;
|
||||||
|
padding: 0 0 $tag_height + 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
right: auto;
|
||||||
|
left: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 7 * $row_height + $tag_height;
|
||||||
|
padding: 0 0 $gap / 2;
|
||||||
|
}
|
19
src/components/tags/TagAutocompleteRow/index.tsx
Normal file
19
src/components/tags/TagAutocompleteRow/index.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
selected: boolean;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagAutocompleteRow: FC<IProps> = ({ selected, type, title }) => (
|
||||||
|
<div className={classNames(styles.row, styles[type], { [styles.selected]: selected })}>
|
||||||
|
<Icon icon={type} size={16} />
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { TagAutocompleteRow };
|
32
src/components/tags/TagAutocompleteRow/styles.module.scss
Normal file
32
src/components/tags/TagAutocompleteRow/styles.module.scss
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
$row_height: 24px;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
height: $row_height;
|
||||||
|
padding: 0 $gap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font: $font_16_semibold;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover, &.selected {
|
||||||
|
opacity: 1;
|
||||||
|
background: transparentize($wisegreen, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
color: lighten($wisegreen, 4%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: 5px;
|
||||||
|
fill: currentColor;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
}
|
||||||
|
}
|
140
src/components/tags/TagInput/index.tsx
Normal file
140
src/components/tags/TagInput/index.tsx
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import React, { ChangeEvent, FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
||||||
|
import { TagAutocomplete } from '~/components/tags/TagAutocomplete';
|
||||||
|
import { TagWrapper } from '~/components/tags/TagWrapper';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
const placeholder = 'Добавить';
|
||||||
|
|
||||||
|
const prepareInput = (input: string): string[] => {
|
||||||
|
return input
|
||||||
|
.split(',')
|
||||||
|
.map((title: string) =>
|
||||||
|
title
|
||||||
|
.trim()
|
||||||
|
.substr(0, 32)
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
|
.filter(el => el.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
onAppend: (tags: string[]) => void;
|
||||||
|
onClearTag: () => string | undefined;
|
||||||
|
onSubmit: (last: string[]) => void;
|
||||||
|
exclude: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const wrapper = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const onInput = useCallback(
|
||||||
|
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
setInput(value || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = prepareInput(value);
|
||||||
|
|
||||||
|
if (items.length > 1) {
|
||||||
|
onAppend(items.slice(0, items.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInput(items[items.length - 1] || '');
|
||||||
|
},
|
||||||
|
[setInput]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onKeyUp = useCallback(
|
||||||
|
({ key }: KeyboardEvent) => {
|
||||||
|
if (key === 'Escape' && ref.current) {
|
||||||
|
setInput('');
|
||||||
|
ref.current.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Backspace' && input === '') {
|
||||||
|
setInput(onClearTag() || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === ',' || key === 'Comma') {
|
||||||
|
const created = prepareInput(input);
|
||||||
|
|
||||||
|
if (created.length) {
|
||||||
|
onAppend(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[input, setInput, onClearTag, onAppend, onSubmit, ref.current, wrapper.current]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFocus = useCallback(() => setFocused(true), []);
|
||||||
|
const onBlur = useCallback(
|
||||||
|
event => {
|
||||||
|
if (wrapper.current.contains(event.target)) {
|
||||||
|
ref.current.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocused(false);
|
||||||
|
|
||||||
|
if (input.trim()) {
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit([]);
|
||||||
|
},
|
||||||
|
[input, onAppend, setInput, onSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAutocompleteSelect = useCallback(
|
||||||
|
(val: string) => {
|
||||||
|
onAppend([val]);
|
||||||
|
setInput('');
|
||||||
|
},
|
||||||
|
[onAppend, setInput]
|
||||||
|
);
|
||||||
|
|
||||||
|
const feature = useMemo(() => (input?.substr(0, 1) === '/' ? 'green' : ''), [input]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('click', onBlur);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('click', onBlur);
|
||||||
|
}, [onBlur]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap} ref={wrapper}>
|
||||||
|
<TagWrapper title={input || placeholder} has_input={true} feature={feature}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
size={1}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={24}
|
||||||
|
onChange={onInput}
|
||||||
|
onKeyDown={onKeyUp}
|
||||||
|
onFocus={onFocus}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
</TagWrapper>
|
||||||
|
|
||||||
|
{onInput && focused && input?.length > 0 && (
|
||||||
|
<TagAutocomplete
|
||||||
|
exclude={exclude}
|
||||||
|
input={ref.current}
|
||||||
|
onSelect={onAutocompleteSelect}
|
||||||
|
search={input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TagInput };
|
4
src/components/tags/TagInput/styles.module.scss
Normal file
4
src/components/tags/TagInput/styles.module.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
40
src/components/tags/TagWrapper/index.tsx
Normal file
40
src/components/tags/TagWrapper/index.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
feature?: string;
|
||||||
|
size?: string;
|
||||||
|
is_hoverable?: boolean;
|
||||||
|
is_editing?: boolean;
|
||||||
|
has_input?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagWrapper: FC<IProps> = ({
|
||||||
|
children,
|
||||||
|
feature,
|
||||||
|
size,
|
||||||
|
is_hoverable,
|
||||||
|
is_editing,
|
||||||
|
has_input,
|
||||||
|
onClick,
|
||||||
|
title = '',
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.tag, feature, size, {
|
||||||
|
is_hoverable,
|
||||||
|
is_editing,
|
||||||
|
input: has_input,
|
||||||
|
clickable: onClick,
|
||||||
|
})}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className={styles.hole} />
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { TagWrapper };
|
|
@ -1,6 +1,9 @@
|
||||||
|
$big: 1.2;
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
@include outer_shadow();
|
@include outer_shadow();
|
||||||
|
|
||||||
|
cursor: default;
|
||||||
height: $tag_height;
|
height: $tag_height;
|
||||||
background: $tag_bg;
|
background: $tag_bg;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -11,8 +14,20 @@
|
||||||
font: $font_14_semibold;
|
font: $font_14_semibold;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0 8px 0 0;
|
padding: 0 8px 0 0;
|
||||||
margin: 0 $gap $gap 0;
|
//margin: 0 $gap $gap 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 12;
|
||||||
|
|
||||||
|
&:global(.big) {
|
||||||
|
height: $tag_height * $big;
|
||||||
|
font: $font_16_semibold;
|
||||||
|
border-radius: ($tag_height * $big / 2) 3px 3px ($tag_height * $big / 2);
|
||||||
|
|
||||||
|
.hole {
|
||||||
|
width: $tag_height * $big;
|
||||||
|
height: $tag_height * $big;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:global(.is_hoverable) {
|
&:global(.is_hoverable) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -56,6 +71,10 @@
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:global(.clickable) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
@ -70,6 +89,7 @@
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 100px;
|
||||||
padding-left: $tag_height;
|
padding-left: $tag_height;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
@ -80,7 +100,6 @@
|
||||||
width: $tag_height;
|
width: $tag_height;
|
||||||
height: $tag_height;
|
height: $tag_height;
|
||||||
display: flex;
|
display: flex;
|
||||||
// padding-right: 0px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 $tag_height;
|
flex: 0 0 $tag_height;
|
||||||
|
@ -101,3 +120,4 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
76
src/components/tags/Tags/index.tsx
Normal file
76
src/components/tags/Tags/index.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import React, { FC, HTMLAttributes, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { TagField } from '~/components/containers/TagField';
|
||||||
|
import { ITag } from '~/redux/types';
|
||||||
|
import uniq from 'ramda/es/uniq';
|
||||||
|
import { Tag } from '~/components/tags/Tag';
|
||||||
|
import { TagInput } from '~/components/tags/TagInput';
|
||||||
|
import { separateTags } from '~/utils/tag';
|
||||||
|
|
||||||
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
tags: Partial<ITag>[];
|
||||||
|
is_editable?: boolean;
|
||||||
|
onTagsChange?: (tags: string[]) => void;
|
||||||
|
onTagClick?: (tag: Partial<ITag>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => {
|
||||||
|
const [data, setData] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [catTags, ordinaryTags] = useMemo(() => separateTags(tags), [tags]);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(last: string[]) => {
|
||||||
|
const exist = tags.map(tag => tag.title);
|
||||||
|
onTagsChange(uniq([...exist, ...data, ...last]));
|
||||||
|
},
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setData(data.filter(title => !tags.some(tag => tag.title.trim() === title.trim())));
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
const onAppendTag = useCallback(
|
||||||
|
(created: string[]) => {
|
||||||
|
setData(uniq([...data, ...created]).filter(title => !tags.some(it => it.title === title)));
|
||||||
|
},
|
||||||
|
[data, setData, tags]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClearTag = useCallback((): string | undefined => {
|
||||||
|
if (!data.length) return;
|
||||||
|
const last = data[data.length - 1];
|
||||||
|
setData(data.slice(0, data.length - 1));
|
||||||
|
return last;
|
||||||
|
}, [data, setData]);
|
||||||
|
|
||||||
|
const exclude = useMemo(() => [...(data || []), ...(tags || []).map(({ title }) => title)], [
|
||||||
|
data,
|
||||||
|
tags,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagField {...props}>
|
||||||
|
{catTags.map(tag => (
|
||||||
|
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{ordinaryTags.map(tag => (
|
||||||
|
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.map(title => (
|
||||||
|
<Tag key={title} tag={{ title }} is_editing />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{is_editable && (
|
||||||
|
<TagInput
|
||||||
|
onAppend={onAppendTag}
|
||||||
|
onClearTag={onClearTag}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
exclude={exclude}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TagField>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,4 +1,4 @@
|
||||||
import { INode, IComment } from '~/redux/types';
|
import { IComment, INode } from '~/redux/types';
|
||||||
import { ISocialProvider } from '~/redux/auth/types';
|
import { ISocialProvider } from '~/redux/auth/types';
|
||||||
|
|
||||||
export const API = {
|
export const API = {
|
||||||
|
@ -46,4 +46,8 @@ export const API = {
|
||||||
BORIS: {
|
BORIS: {
|
||||||
GET_BACKEND_STATS: '/stats/',
|
GET_BACKEND_STATS: '/stats/',
|
||||||
},
|
},
|
||||||
|
TAG: {
|
||||||
|
NODES: `/tag/nodes`,
|
||||||
|
AUTOCOMPLETE: `/tag/autocomplete`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,6 +13,7 @@ export const URLS = {
|
||||||
BACKEND_DOWN: '/oopsie',
|
BACKEND_DOWN: '/oopsie',
|
||||||
},
|
},
|
||||||
NODE_URL: (id: number | string) => `/post${id}`,
|
NODE_URL: (id: number | string) => `/post${id}`,
|
||||||
|
NODE_TAG_URL: (id: number, tagName: string) => `/post${id}/tag/${tagName}`,
|
||||||
PROFILE: (username: string) => `/~${username}`,
|
PROFILE: (username: string) => `/~${username}`,
|
||||||
PROFILE_PAGE: (username: string) => `/profile/${username}`,
|
PROFILE_PAGE: (username: string) => `/profile/${username}`,
|
||||||
};
|
};
|
||||||
|
|
15
src/containers/main/SidebarRouter/index.tsx
Normal file
15
src/containers/main/SidebarRouter/index.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Route, Switch } from 'react-router';
|
||||||
|
import { TagSidebar } from '~/containers/sidebars/TagSidebar';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarRouter: FC<IProps> = ({ prefix = '' }) => (
|
||||||
|
<Switch>
|
||||||
|
<Route path={`${prefix}/tag/:tag`} component={TagSidebar} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { SidebarRouter };
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { FC, createElement, useEffect, useCallback, useState, useMemo, memo } from 'react';
|
import React, { createElement, FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps, useHistory } from 'react-router';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { canEditNode, canLikeNode, canStarNode } from '~/utils/node';
|
import { canEditNode, canLikeNode, canStarNode } from '~/utils/node';
|
||||||
import { selectNode } from '~/redux/node/selectors';
|
import { selectNode } from '~/redux/node/selectors';
|
||||||
|
@ -12,12 +12,7 @@ import { NodeNoComments } from '~/components/node/NodeNoComments';
|
||||||
import { NodeRelated } from '~/components/node/NodeRelated';
|
import { NodeRelated } from '~/components/node/NodeRelated';
|
||||||
import { NodeComments } from '~/components/node/NodeComments';
|
import { NodeComments } from '~/components/node/NodeComments';
|
||||||
import { NodeTags } from '~/components/node/NodeTags';
|
import { NodeTags } from '~/components/node/NodeTags';
|
||||||
import {
|
import { INodeComponentProps, NODE_COMPONENTS, NODE_HEADS, NODE_INLINES, } from '~/redux/node/constants';
|
||||||
NODE_COMPONENTS,
|
|
||||||
NODE_INLINES,
|
|
||||||
NODE_HEADS,
|
|
||||||
INodeComponentProps,
|
|
||||||
} from '~/redux/node/constants';
|
|
||||||
import { selectUser } from '~/redux/auth/selectors';
|
import { selectUser } from '~/redux/auth/selectors';
|
||||||
import pick from 'ramda/es/pick';
|
import pick from 'ramda/es/pick';
|
||||||
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
|
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
|
||||||
|
@ -25,12 +20,16 @@ import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
|
||||||
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
||||||
import { Sticky } from '~/components/containers/Sticky';
|
import { Sticky } from '~/components/containers/Sticky';
|
||||||
import { Footer } from '~/components/main/Footer';
|
import { Footer } from '~/components/main/Footer';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import * as NODE_ACTIONS from '~/redux/node/actions';
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
import { IState } from '~/redux/store';
|
import { IState } from '~/redux/store';
|
||||||
import { selectModal } from '~/redux/modal/selectors';
|
import { selectModal } from '~/redux/modal/selectors';
|
||||||
|
import { SidebarRouter } from '~/containers/main/SidebarRouter';
|
||||||
|
import { ITag } from '~/redux/types';
|
||||||
|
import { URLS } from '~/constants/urls';
|
||||||
|
|
||||||
const mapStateToProps = (state: IState) => ({
|
const mapStateToProps = (state: IState) => ({
|
||||||
node: selectNode(state),
|
node: selectNode(state),
|
||||||
|
@ -86,6 +85,7 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
modalShowPhotoswipe,
|
modalShowPhotoswipe,
|
||||||
}) => {
|
}) => {
|
||||||
const [layout, setLayout] = useState({});
|
const [layout, setLayout] = useState({});
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const updateLayout = useCallback(() => setLayout({}), []);
|
const updateLayout = useCallback(() => setLayout({}), []);
|
||||||
|
|
||||||
|
@ -101,6 +101,13 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
[node, nodeUpdateTags]
|
[node, nodeUpdateTags]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onTagClick = useCallback(
|
||||||
|
(tag: Partial<ITag>) => {
|
||||||
|
history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title)));
|
||||||
|
},
|
||||||
|
[history, node.id]
|
||||||
|
);
|
||||||
|
|
||||||
const can_edit = useMemo(() => canEditNode(node, user), [node, user]);
|
const can_edit = useMemo(() => canEditNode(node, user), [node, user]);
|
||||||
const can_like = useMemo(() => canLikeNode(node, user), [node, user]);
|
const can_like = useMemo(() => canLikeNode(node, user), [node, user]);
|
||||||
const can_star = useMemo(() => canStarNode(node, user), [node, user]);
|
const can_star = useMemo(() => canStarNode(node, user), [node, user]);
|
||||||
|
@ -197,6 +204,7 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
is_editable={is_user}
|
is_editable={is_user}
|
||||||
tags={node.tags}
|
tags={node.tags}
|
||||||
onChange={onTagsChange}
|
onChange={onTagsChange}
|
||||||
|
onTagClick={onTagClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -209,7 +217,11 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
.filter(album => related.albums[album].length > 0)
|
.filter(album => related.albums[album].length > 0)
|
||||||
.map(album => (
|
.map(album => (
|
||||||
<NodeRelated
|
<NodeRelated
|
||||||
title={album}
|
title={
|
||||||
|
<Link to={URLS.NODE_TAG_URL(node.id, encodeURIComponent(album))}>
|
||||||
|
{album}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
items={related.albums[album]}
|
items={related.albums[album]}
|
||||||
key={album}
|
key={album}
|
||||||
/>
|
/>
|
||||||
|
@ -231,6 +243,8 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<SidebarRouter prefix="/post:id" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,8 @@
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding-left: $gap / 2;
|
padding-left: $gap / 2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|
34
src/containers/sidebars/SidebarWrapper/index.tsx
Normal file
34
src/containers/sidebars/SidebarWrapper/index.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import React, { FC, useEffect, useRef } from 'react';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
|
||||||
|
import { useCloseOnEscape } from '~/utils/hooks';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarWrapper: FC<IProps> = ({ children, onClose }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useCloseOnEscape(onClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
disableBodyScroll(ref.current, { reserveScrollBarGap: true });
|
||||||
|
|
||||||
|
return () => enableBodyScroll(ref.current);
|
||||||
|
}, [ref.current]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.clicker} onClick={onClose} />
|
||||||
|
<div className={styles.content} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { SidebarWrapper };
|
55
src/containers/sidebars/SidebarWrapper/styles.module.scss
Normal file
55
src/containers/sidebars/SidebarWrapper/styles.module.scss
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
@keyframes appear {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
z-index: 20;
|
||||||
|
justify-content: flex-end;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: appear 0.25s forwards;
|
||||||
|
|
||||||
|
@include can_backdrop {
|
||||||
|
background: transparentize($content_bg, 0.15);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 0 1 33vw;
|
||||||
|
width: 33vw;
|
||||||
|
min-width: 480px;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
animation: slideIn 0.5s 0.1s forwards;
|
||||||
|
transform: translate(100%, 0);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clicker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
102
src/containers/sidebars/TagSidebar/index.tsx
Normal file
102
src/containers/sidebars/TagSidebar/index.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import React, { FC, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { SidebarWrapper } from '~/containers/sidebars/SidebarWrapper';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { useHistory, useRouteMatch } from 'react-router';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { TagSidebarList } from '~/components/sidebar/TagSidebarList';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { selectTagNodes } from '~/redux/tag/selectors';
|
||||||
|
import * as ACTIONS from '~/redux/tag/actions';
|
||||||
|
import { LoaderCircle } from '~/components/input/LoaderCircle';
|
||||||
|
import { InfiniteScroll } from '~/components/containers/InfiniteScroll';
|
||||||
|
import { Tag } from '~/components/tags/Tag';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
nodes: selectTagNodes(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
tagLoadNodes: ACTIONS.tagLoadNodes,
|
||||||
|
tagSetNodes: ACTIONS.tagSetNodes,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
|
const TagSidebarUnconnected: FC<Props> = ({ nodes, tagLoadNodes, tagSetNodes }) => {
|
||||||
|
const {
|
||||||
|
params: { tag },
|
||||||
|
url,
|
||||||
|
} = useRouteMatch<{ tag: string }>();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const basePath = url.replace(new RegExp(`\/tag\/${tag}$`), '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tagLoadNodes(tag);
|
||||||
|
return () => tagSetNodes({ list: [], count: 0 });
|
||||||
|
}, [tag]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (nodes.isLoading) return;
|
||||||
|
tagLoadNodes(tag);
|
||||||
|
}, [tagLoadNodes, tag, nodes.isLoading]);
|
||||||
|
|
||||||
|
const title = useMemo(() => decodeURIComponent(tag), [tag]);
|
||||||
|
const progress = nodes.count > 0 ? `${(nodes.list.length / nodes.count) * 100}%` : '0';
|
||||||
|
|
||||||
|
const onClose = useCallback(() => history.push(basePath), [basePath]);
|
||||||
|
const hasMore = nodes.count > nodes.list.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarWrapper onClose={onClose}>
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.head}>
|
||||||
|
{nodes.count > 0 && (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<div className={styles.bar} style={{ width: progress }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.tag}>
|
||||||
|
<Tag tag={{ title }} size="big" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodes.isLoading && (
|
||||||
|
<div className={styles.sync}>
|
||||||
|
<LoaderCircle size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.close}>
|
||||||
|
<Link to={basePath}>
|
||||||
|
<Icon icon="close" size={32} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!nodes.count && !nodes.isLoading ? (
|
||||||
|
<div className={styles.none}>
|
||||||
|
<Icon icon="sad" size={120} />
|
||||||
|
<div>
|
||||||
|
У этого тэга нет постов
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Такие дела
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<InfiniteScroll hasMore={hasMore} loadMore={loadMore} className={styles.list}>
|
||||||
|
<TagSidebarList nodes={nodes.list} />
|
||||||
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagSidebar = connect(mapStateToProps, mapDispatchToProps)(TagSidebarUnconnected);
|
||||||
|
|
||||||
|
export { TagSidebar };
|
105
src/containers/sidebars/TagSidebar/styles.module.scss
Normal file
105
src/containers/sidebars/TagSidebar/styles.module.scss
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
.wrap {
|
||||||
|
@include outer_shadow;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 1 400px;
|
||||||
|
max-width: 100vw;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background: $content_bg;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $gap;
|
||||||
|
background: lighten($content_bg, 2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: white;
|
||||||
|
stroke: white;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
padding: $gap;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync {
|
||||||
|
padding-left: $gap * 2;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: transparentize(white, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: darken($content_bg, 2%);
|
||||||
|
height: 2px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: lighten($content_bg, 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.none {
|
||||||
|
padding: 40px $gap;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: transparentize(white, 0.9);
|
||||||
|
fill: currentColor;
|
||||||
|
font: $font_18_semibold;
|
||||||
|
stroke: none;
|
||||||
|
line-height: 26px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
div {
|
||||||
|
margin-top: 24px;
|
||||||
|
max-width: 200px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { FC, ReactElement } from 'react';
|
import { FC } from 'react';
|
||||||
import { INode, ValueOf, IComment } from '../types';
|
import { IComment, INode, ValueOf } from '../types';
|
||||||
import { NodeImageSlideBlock } from '~/components/node/NodeImageSlideBlock';
|
import { NodeImageSlideBlock } from '~/components/node/NodeImageSlideBlock';
|
||||||
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
||||||
import { NodeAudioBlock } from '~/components/node/NodeAudioBlock';
|
import { NodeAudioBlock } from '~/components/node/NodeAudioBlock';
|
||||||
|
@ -12,7 +12,6 @@ import { AudioEditor } from '~/components/editors/AudioEditor';
|
||||||
import { EditorImageUploadButton } from '~/components/editors/EditorImageUploadButton';
|
import { EditorImageUploadButton } from '~/components/editors/EditorImageUploadButton';
|
||||||
import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadButton';
|
import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadButton';
|
||||||
import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton';
|
import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton';
|
||||||
import { Filler } from '~/components/containers/Filler';
|
|
||||||
import { modalShowPhotoswipe } from '../modal/actions';
|
import { modalShowPhotoswipe } from '../modal/actions';
|
||||||
import { IEditorComponentProps } from '~/redux/node/types';
|
import { IEditorComponentProps } from '~/redux/node/types';
|
||||||
import { EditorFiller } from '~/components/editors/EditorFiller';
|
import { EditorFiller } from '~/components/editors/EditorFiller';
|
||||||
|
|
|
@ -34,6 +34,9 @@ import borisSaga from './boris/sagas';
|
||||||
import messages, { IMessagesState } from './messages';
|
import messages, { IMessagesState } from './messages';
|
||||||
import messagesSaga from './messages/sagas';
|
import messagesSaga from './messages/sagas';
|
||||||
|
|
||||||
|
import tag, { ITagState } from './tag';
|
||||||
|
import tagSaga from './tag/sagas';
|
||||||
|
|
||||||
const authPersistConfig: PersistConfig = {
|
const authPersistConfig: PersistConfig = {
|
||||||
key: 'auth',
|
key: 'auth',
|
||||||
whitelist: ['token', 'user', 'updates'],
|
whitelist: ['token', 'user', 'updates'],
|
||||||
|
@ -62,6 +65,7 @@ export interface IState {
|
||||||
player: IPlayerState;
|
player: IPlayerState;
|
||||||
boris: IBorisState;
|
boris: IBorisState;
|
||||||
messages: IMessagesState;
|
messages: IMessagesState;
|
||||||
|
tag: ITagState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sagaMiddleware = createSagaMiddleware();
|
export const sagaMiddleware = createSagaMiddleware();
|
||||||
|
@ -83,6 +87,7 @@ export const store = createStore(
|
||||||
flow: persistReducer(flowPersistConfig, flow),
|
flow: persistReducer(flowPersistConfig, flow),
|
||||||
player: persistReducer(playerPersistConfig, player),
|
player: persistReducer(playerPersistConfig, player),
|
||||||
messages,
|
messages,
|
||||||
|
tag: tag,
|
||||||
}),
|
}),
|
||||||
composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware))
|
composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware))
|
||||||
);
|
);
|
||||||
|
@ -99,6 +104,7 @@ export function configureStore(): {
|
||||||
sagaMiddleware.run(modalSaga);
|
sagaMiddleware.run(modalSaga);
|
||||||
sagaMiddleware.run(borisSaga);
|
sagaMiddleware.run(borisSaga);
|
||||||
sagaMiddleware.run(messagesSaga);
|
sagaMiddleware.run(messagesSaga);
|
||||||
|
sagaMiddleware.run(tagSaga);
|
||||||
|
|
||||||
window.addEventListener('message', message => {
|
window.addEventListener('message', message => {
|
||||||
if (message && message.data && message.data.type === 'oauth_login' && message.data.token)
|
if (message && message.data && message.data.type === 'oauth_login' && message.data.token)
|
||||||
|
|
23
src/redux/tag/actions.ts
Normal file
23
src/redux/tag/actions.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { ITagState } from '~/redux/tag/index';
|
||||||
|
import { TAG_ACTIONS } from '~/redux/tag/constants';
|
||||||
|
|
||||||
|
export const tagSetNodes = (nodes: Partial<ITagState['nodes']>) => ({
|
||||||
|
type: TAG_ACTIONS.SET_TAG_NODES,
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagLoadNodes = (tag: string) => ({
|
||||||
|
type: TAG_ACTIONS.LOAD_NODES,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagSetAutocomplete = (autocomplete: Partial<ITagState['autocomplete']>) => ({
|
||||||
|
type: TAG_ACTIONS.SET_TAG_AUTOCOMPLETE,
|
||||||
|
autocomplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagLoadAutocomplete = (search: string, exclude: string[]) => ({
|
||||||
|
type: TAG_ACTIONS.LOAD_AUTOCOMPLETE,
|
||||||
|
search,
|
||||||
|
exclude,
|
||||||
|
});
|
33
src/redux/tag/api.ts
Normal file
33
src/redux/tag/api.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { INode, IResultWithStatus } from '~/redux/types';
|
||||||
|
import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api';
|
||||||
|
import { API } from '~/constants/api';
|
||||||
|
|
||||||
|
export const getTagNodes = ({
|
||||||
|
access,
|
||||||
|
tag,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
access: string;
|
||||||
|
tag: string;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<IResultWithStatus<{ nodes: INode[]; count: number }>> =>
|
||||||
|
api
|
||||||
|
.get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } }))
|
||||||
|
.then(resultMiddleware)
|
||||||
|
.catch(errorMiddleware);
|
||||||
|
|
||||||
|
export const getTagAutocomplete = ({
|
||||||
|
search,
|
||||||
|
exclude,
|
||||||
|
access,
|
||||||
|
}: {
|
||||||
|
access: string;
|
||||||
|
search: string;
|
||||||
|
exclude: string[];
|
||||||
|
}): Promise<IResultWithStatus<{ tags: string[] }>> =>
|
||||||
|
api
|
||||||
|
.get(API.TAG.AUTOCOMPLETE, configWithToken(access, { params: { search, exclude } }))
|
||||||
|
.then(resultMiddleware)
|
||||||
|
.catch(errorMiddleware);
|
8
src/redux/tag/constants.ts
Normal file
8
src/redux/tag/constants.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
const prefix = 'TAG.';
|
||||||
|
|
||||||
|
export const TAG_ACTIONS = {
|
||||||
|
LOAD_NODES: `${prefix}LOAD_TAG_NODES`,
|
||||||
|
SET_TAG_NODES: `${prefix}SET_TAG_NODES`,
|
||||||
|
SET_TAG_AUTOCOMPLETE: `${prefix}SET_TAG_NODES`,
|
||||||
|
LOAD_AUTOCOMPLETE: `${prefix}LOAD_TAG_AUTOCOMPLETE`,
|
||||||
|
};
|
27
src/redux/tag/handlers.ts
Normal file
27
src/redux/tag/handlers.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { TAG_ACTIONS } from '~/redux/tag/constants';
|
||||||
|
import { ITagState } from '~/redux/tag/index';
|
||||||
|
import { tagSetAutocomplete, tagSetNodes } from '~/redux/tag/actions';
|
||||||
|
|
||||||
|
const setNodes = (state: ITagState, { nodes }: ReturnType<typeof tagSetNodes>) => ({
|
||||||
|
...state,
|
||||||
|
nodes: {
|
||||||
|
...state.nodes,
|
||||||
|
...nodes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAutocomplete = (
|
||||||
|
state: ITagState,
|
||||||
|
{ autocomplete }: ReturnType<typeof tagSetAutocomplete>
|
||||||
|
) => ({
|
||||||
|
...state,
|
||||||
|
autocomplete: {
|
||||||
|
...state.autocomplete,
|
||||||
|
...autocomplete,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TAG_HANDLERS = {
|
||||||
|
[TAG_ACTIONS.SET_TAG_NODES]: setNodes,
|
||||||
|
[TAG_ACTIONS.SET_TAG_AUTOCOMPLETE]: setAutocomplete,
|
||||||
|
};
|
29
src/redux/tag/index.ts
Normal file
29
src/redux/tag/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { createReducer } from '~/utils/reducer';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { TAG_HANDLERS } from '~/redux/tag/handlers';
|
||||||
|
|
||||||
|
export interface ITagState {
|
||||||
|
nodes: {
|
||||||
|
list: INode[];
|
||||||
|
count: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
autocomplete: {
|
||||||
|
isLoading: boolean;
|
||||||
|
options: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: ITagState = {
|
||||||
|
nodes: {
|
||||||
|
list: [],
|
||||||
|
count: 0,
|
||||||
|
isLoading: true,
|
||||||
|
},
|
||||||
|
autocomplete: {
|
||||||
|
isLoading: true,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createReducer(INITIAL_STATE, TAG_HANDLERS);
|
53
src/redux/tag/sagas.ts
Normal file
53
src/redux/tag/sagas.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
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 { selectTagNodes } from '~/redux/tag/selectors';
|
||||||
|
import { getTagAutocomplete, getTagNodes } from '~/redux/tag/api';
|
||||||
|
import { Unwrap } from '~/redux/types';
|
||||||
|
|
||||||
|
function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
|
||||||
|
yield put(tagSetNodes({ isLoading: true }));
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
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({ isLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function* loadAutocomplete({ search, exclude }: ReturnType<typeof tagLoadAutocomplete>) {
|
||||||
|
if (search.length < 3) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
yield put(tagSetAutocomplete({ isLoading: true }));
|
||||||
|
yield delay(100);
|
||||||
|
|
||||||
|
const { data, error }: Unwrap<ReturnType<typeof getTagAutocomplete>> = yield call(
|
||||||
|
reqWrapper,
|
||||||
|
getTagAutocomplete,
|
||||||
|
{ search, exclude }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) throw new Error(error);
|
||||||
|
|
||||||
|
yield put(tagSetAutocomplete({ options: data.tags, isLoading: false }));
|
||||||
|
} catch (e) {
|
||||||
|
yield put(tagSetAutocomplete({ isLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function* tagSaga() {
|
||||||
|
yield takeLatest(TAG_ACTIONS.LOAD_NODES, loadTagNodes);
|
||||||
|
yield takeLatest(TAG_ACTIONS.LOAD_AUTOCOMPLETE, loadAutocomplete);
|
||||||
|
}
|
5
src/redux/tag/selectors.ts
Normal file
5
src/redux/tag/selectors.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { IState } from '~/redux/store';
|
||||||
|
|
||||||
|
export const selectTag = (state: IState) => state.tag;
|
||||||
|
export const selectTagNodes = (state: IState) => state.tag.nodes;
|
||||||
|
export const selectTagAutocomplete = (state: IState) => state.tag.autocomplete;
|
|
@ -160,6 +160,11 @@ const Sprites: FC<{}> = () => (
|
||||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
<g id="keyboard" stroke="none">
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
<g id="key" stroke="none">
|
<g id="key" stroke="none">
|
||||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
|
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
|
||||||
|
@ -207,11 +212,24 @@ const Sprites: FC<{}> = () => (
|
||||||
<path d="M14 12c0-1.1-.9-2-2-2s-2 .9-2 2 .9 2 2 2 2-.9 2-2zm-2-9c-4.97 0-9 4.03-9 9H0l4 4 4-4H5c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.51 0-2.91-.49-4.06-1.3l-1.42 1.44C8.04 20.3 9.94 21 12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9z" />
|
<path d="M14 12c0-1.1-.9-2-2-2s-2 .9-2 2 .9 2 2 2 2-.9 2-2zm-2-9c-4.97 0-9 4.03-9 9H0l4 4 4-4H5c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.51 0-2.91-.49-4.06-1.3l-1.42 1.44C8.04 20.3 9.94 21 12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9z" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
<g id="sad" stroke="none">
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||||
|
<circle cx="15.5" cy="9.5" r="1.5" />
|
||||||
|
<circle cx="8.5" cy="9.5" r="1.5" />
|
||||||
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-3.5c.73 0 1.39.19 1.97.53.12-.14.86-.98 1.01-1.14-.85-.56-1.87-.89-2.98-.89-1.11 0-2.13.33-2.99.88.97 1.09.01.02 1.01 1.14.59-.33 1.25-.52 1.98-.52z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
<g id="settings" stroke="none">
|
<g id="settings" stroke="none">
|
||||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" />
|
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
<g id="tag" stroke="none">
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
<path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16zM16 17H5V7h11l3.55 5L16 17z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
<g id="messages" stroke="none">
|
<g id="messages" stroke="none">
|
||||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z" />
|
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z" />
|
||||||
|
|
|
@ -175,3 +175,13 @@ $login_dialog_padding: $gap $gap 30px $gap;
|
||||||
background: transparentize(white, 0.95);
|
background: transparentize(white, 0.95);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin clamp($lines, $line: 1em) {
|
||||||
|
max-height: $line * $lines;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-line-clamp: $lines;
|
||||||
|
line-clamp: $lines;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
|
@ -23,15 +23,12 @@ export const HTTP_RESPONSES = {
|
||||||
TOO_MANY_REQUESTS: 429,
|
TOO_MANY_REQUESTS: 429,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resultMiddleware = <T extends {}>({
|
export const resultMiddleware = <T extends any>({ status, data }: { status; data: T }) => ({
|
||||||
status,
|
status,
|
||||||
data,
|
data,
|
||||||
}: {
|
});
|
||||||
status: number;
|
|
||||||
data: T;
|
|
||||||
}): { status: number; data: T } => ({ status, data });
|
|
||||||
|
|
||||||
export const errorMiddleware = <T extends any>(debug): IResultWithStatus<T> =>
|
export const errorMiddleware = <T extends any = any>(debug): IResultWithStatus<T> =>
|
||||||
debug && debug.response
|
debug && debug.response
|
||||||
? {
|
? {
|
||||||
status: debug.response.status,
|
status: debug.response.status,
|
||||||
|
@ -41,7 +38,7 @@ export const errorMiddleware = <T extends any>(debug): IResultWithStatus<T> =>
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
status: HTTP_RESPONSES.CONNECTION_REFUSED,
|
status: HTTP_RESPONSES.CONNECTION_REFUSED,
|
||||||
data: {} as T & IApiErrorResult,
|
data: {} as T & IApiErrorResult & any,
|
||||||
debug,
|
debug,
|
||||||
error: 'Ошибка сети',
|
error: 'Ошибка сети',
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,7 +6,7 @@ import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||||
import ru from 'date-fns/locale/ru';
|
import ru from 'date-fns/locale/ru';
|
||||||
import Axios from 'axios';
|
import Axios from 'axios';
|
||||||
import { PRESETS } from '~/constants/urls';
|
import { PRESETS } from '~/constants/urls';
|
||||||
import { ICommentBlock, COMMENT_BLOCK_DETECTORS, COMMENT_BLOCK_TYPES } from '~/constants/comment';
|
import { COMMENT_BLOCK_DETECTORS, COMMENT_BLOCK_TYPES, ICommentBlock } from '~/constants/comment';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
|
|
||||||
export const getStyle = (oElm: any, strCssRule: string) => {
|
export const getStyle = (oElm: any, strCssRule: string) => {
|
||||||
|
@ -63,20 +63,25 @@ export const describeArc = (
|
||||||
].join(' ');
|
].join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getURL = (file: Partial<IFile>, size?: typeof PRESETS[keyof typeof PRESETS]) => {
|
export const getURLFromString = (
|
||||||
if (!file || !file.url) return null;
|
url: string,
|
||||||
|
size?: typeof PRESETS[keyof typeof PRESETS]
|
||||||
|
): string => {
|
||||||
if (size) {
|
if (size) {
|
||||||
return file.url
|
return url
|
||||||
.replace('REMOTE_CURRENT://', `${process.env.REMOTE_CURRENT}cache/${size}/`)
|
.replace('REMOTE_CURRENT://', `${process.env.REMOTE_CURRENT}cache/${size}/`)
|
||||||
.replace('REMOTE_OLD://', process.env.REMOTE_OLD);
|
.replace('REMOTE_OLD://', process.env.REMOTE_OLD);
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.url
|
return url
|
||||||
.replace('REMOTE_CURRENT://', process.env.REMOTE_CURRENT)
|
.replace('REMOTE_CURRENT://', process.env.REMOTE_CURRENT)
|
||||||
.replace('REMOTE_OLD://', process.env.REMOTE_OLD);
|
.replace('REMOTE_OLD://', process.env.REMOTE_OLD);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getURL = (file: Partial<IFile>, size?: typeof PRESETS[keyof typeof PRESETS]) => {
|
||||||
|
return file?.url ? getURLFromString(file.url, size) : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const formatText = (text: string): string =>
|
export const formatText = (text: string): string =>
|
||||||
!text
|
!text
|
||||||
? ''
|
? ''
|
||||||
|
|
13
src/utils/tag.ts
Normal file
13
src/utils/tag.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { ITag } from '~/redux/types';
|
||||||
|
|
||||||
|
export const separateTags = (tags: Partial<ITag>[]): Partial<ITag>[][] =>
|
||||||
|
(tags || []).reduce(
|
||||||
|
(obj, tag) =>
|
||||||
|
tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]],
|
||||||
|
[[], []]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const separateTagOptions = (options: string[]): string[][] =>
|
||||||
|
separateTags(options.map((title): Partial<ITag> => ({ title }))).map(item =>
|
||||||
|
item.map(({ title }) => title)
|
||||||
|
);
|
|
@ -176,6 +176,7 @@ module.exports = () => {
|
||||||
contentBase: 'dist',
|
contentBase: 'dist',
|
||||||
publicPath: '/',
|
publicPath: '/',
|
||||||
hot: true,
|
hot: true,
|
||||||
|
open: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -9896,10 +9896,10 @@ typescript-tuple@^2.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript-compare "^0.0.2"
|
typescript-compare "^0.0.2"
|
||||||
|
|
||||||
typescript@^3.6.4:
|
typescript@^3.7.2:
|
||||||
version "3.7.2"
|
version "3.9.7"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.2.tgz#27e489b95fa5909445e9fef5ee48d81697ad18fb"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa"
|
||||||
integrity sha512-ml7V7JfiN2Xwvcer+XAf2csGO1bPBdRbFCkYBczNZggrBZ9c7G3riSUeJmqEU5uOtXNPMhE3n+R4FA/3YOAWOQ==
|
integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==
|
||||||
|
|
||||||
uglify-es@^3.3.4:
|
uglify-es@^3.3.4:
|
||||||
version "3.3.9"
|
version "3.3.9"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue