mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 12:56:41 +07:00
Merge branch 'master' into develop
This commit is contained in:
commit
ab1bfd600a
52 changed files with 1264 additions and 226 deletions
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;
|
||||
justify-content: flex-start;
|
||||
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 { getURL, formatCellText } from '~/utils/dom';
|
||||
import { formatCellText, getURL } from '~/utils/dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import * as styles from './styles.scss';
|
||||
|
@ -109,6 +109,16 @@ const Cell: FC<IProps> = ({
|
|||
return getURL({ url: thumbnail }, preset);
|
||||
}, [thumbnail, flow]);
|
||||
|
||||
const titleSize = useMemo(() => {
|
||||
if (title.length > 100) {
|
||||
return styles.small;
|
||||
} else if (title.length > 64) {
|
||||
return styles.medium;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.cell, styles[(flow && flow.display) || 'single'])} ref={ref}>
|
||||
{is_visible && (
|
||||
|
@ -134,7 +144,7 @@ const Cell: FC<IProps> = ({
|
|||
|
||||
<Link className={classNames(styles.face)} to={`/post${id}`}>
|
||||
<div className={styles.face_content}>
|
||||
{!text && <div className={styles.title}>{title || '...'}</div>}
|
||||
{!text && <div className={classNames(styles.title, titleSize)}>{title || '...'}</div>}
|
||||
|
||||
{!!text && !!thumbnail && (
|
||||
<div className={styles.text}>
|
||||
|
|
|
@ -95,10 +95,21 @@
|
|||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
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 {
|
||||
margin-bottom: $gap / 2;
|
||||
@include clamp(3, 1.25em)
|
||||
}
|
||||
|
||||
.horizontal,
|
||||
|
|
|
@ -54,4 +54,7 @@
|
|||
font: $font_12_regular;
|
||||
margin-top: 4px;
|
||||
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 { Group } from '~/components/containers/Group';
|
||||
import { INode } from '~/redux/types';
|
||||
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
||||
|
||||
interface IProps {
|
||||
title: string;
|
||||
title: ReactElement | string;
|
||||
items: Partial<INode>[];
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,11 @@
|
|||
|
||||
.title {
|
||||
@include title_with_line();
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import React, { FC, memo } from 'react';
|
||||
import { Tags } from '../Tags';
|
||||
import { ITag } from '~/redux/types';
|
||||
import { Tags } from '~/components/tags/Tags';
|
||||
|
||||
interface IProps {
|
||||
is_editable?: boolean;
|
||||
tags: ITag[];
|
||||
onChange?: (tags: string[]) => void;
|
||||
onTagClick?: (tag: Partial<ITag>) => void;
|
||||
}
|
||||
|
||||
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange }) => (
|
||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
||||
));
|
||||
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange, onTagClick }) => {
|
||||
return (
|
||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} onTagClick={onTagClick} />
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
@include outer_shadow();
|
||||
|
||||
cursor: default;
|
||||
height: $tag_height;
|
||||
background: $tag_bg;
|
||||
display: flex;
|
||||
|
@ -11,8 +14,20 @@
|
|||
font: $font_14_semibold;
|
||||
align-self: flex-start;
|
||||
padding: 0 8px 0 0;
|
||||
margin: 0 $gap $gap 0;
|
||||
//margin: 0 $gap $gap 0;
|
||||
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) {
|
||||
cursor: pointer;
|
||||
|
@ -56,6 +71,10 @@
|
|||
min-width: 100px;
|
||||
}
|
||||
|
||||
&:global(.clickable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
background: none;
|
||||
border: none;
|
||||
|
@ -70,6 +89,7 @@
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
min-width: 100px;
|
||||
padding-left: $tag_height;
|
||||
padding-right: 5px;
|
||||
box-sizing: border-box;
|
||||
|
@ -80,7 +100,6 @@
|
|||
width: $tag_height;
|
||||
height: $tag_height;
|
||||
display: flex;
|
||||
// padding-right: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 $tag_height;
|
||||
|
@ -101,3 +120,4 @@
|
|||
overflow: hidden;
|
||||
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>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue