diff --git a/package.json b/package.json index 1767cc16..4b0f01c0 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "resolve-url-loader": "^3.0.1", "style-loader": "^0.21.0", "ts-node": "^8.4.1", - "typescript": "^3.6.4", + "typescript": "^3.7.2", "uglifyjs-webpack-plugin": "^1.3.0", "webpack": "^4.41.2", "webpack-cli": "^3.3.9", diff --git a/src/components/containers/InfiniteScroll/index.tsx b/src/components/containers/InfiniteScroll/index.tsx new file mode 100644 index 00000000..70db3103 --- /dev/null +++ b/src/components/containers/InfiniteScroll/index.tsx @@ -0,0 +1,42 @@ +import React, { FC, HTMLAttributes, useCallback, useEffect, useRef } from 'react'; +import styles from './styles.module.scss'; + +interface IProps extends HTMLAttributes { + hasMore: boolean; + scrollReactPx?: number; + loadMore: () => void; +} + +const InfiniteScroll: FC = ({ children, hasMore, scrollReactPx, loadMore, ...props }) => { + const ref = useRef(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 ( +
+ {children} + {hasMore &&
} +
+ ); +}; + +export { InfiniteScroll }; diff --git a/src/components/containers/InfiniteScroll/styles.module.scss b/src/components/containers/InfiniteScroll/styles.module.scss new file mode 100644 index 00000000..9fd1a252 --- /dev/null +++ b/src/components/containers/InfiniteScroll/styles.module.scss @@ -0,0 +1,2 @@ +.more { +} diff --git a/src/components/containers/TagField/styles.scss b/src/components/containers/TagField/styles.scss index f65e12d0..18edac3e 100644 --- a/src/components/containers/TagField/styles.scss +++ b/src/components/containers/TagField/styles.scss @@ -3,4 +3,8 @@ align-items: flex-start; justify-content: flex-start; flex-wrap: wrap; + + &> * { + margin: 0 $gap $gap 0; + } } diff --git a/src/components/flow/Cell/index.tsx b/src/components/flow/Cell/index.tsx index 07b5a323..d83ec144 100644 --- a/src/components/flow/Cell/index.tsx +++ b/src/components/flow/Cell/index.tsx @@ -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 = ({ 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 (
{is_visible && ( @@ -134,7 +144,7 @@ const Cell: FC = ({
- {!text &&
{title || '...'}
} + {!text &&
{title || '...'}
} {!!text && !!thumbnail && (
diff --git a/src/components/flow/Cell/styles.scss b/src/components/flow/Cell/styles.scss index 8f215692..72a6ed00 100644 --- a/src/components/flow/Cell/styles.scss +++ b/src/components/flow/Cell/styles.scss @@ -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, diff --git a/src/components/flow/FlowRecentItem/styles.scss b/src/components/flow/FlowRecentItem/styles.scss index d103d429..42381a98 100644 --- a/src/components/flow/FlowRecentItem/styles.scss +++ b/src/components/flow/FlowRecentItem/styles.scss @@ -54,4 +54,7 @@ font: $font_12_regular; margin-top: 4px; opacity: 0.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/src/components/node/NodeRelated/index.tsx b/src/components/node/NodeRelated/index.tsx index f119d9f2..4b246328 100644 --- a/src/components/node/NodeRelated/index.tsx +++ b/src/components/node/NodeRelated/index.tsx @@ -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[]; } diff --git a/src/components/node/NodeRelated/styles.scss b/src/components/node/NodeRelated/styles.scss index 1a47f904..2ffd5464 100644 --- a/src/components/node/NodeRelated/styles.scss +++ b/src/components/node/NodeRelated/styles.scss @@ -20,6 +20,11 @@ .title { @include title_with_line(); + + a { + text-decoration: none; + color: inherit; + } } .text { diff --git a/src/components/node/NodeTags/index.tsx b/src/components/node/NodeTags/index.tsx index 6c541051..d5c48923 100644 --- a/src/components/node/NodeTags/index.tsx +++ b/src/components/node/NodeTags/index.tsx @@ -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) => void; } -const NodeTags: FC = memo(({ is_editable, tags, onChange }) => ( - -)); +const NodeTags: FC = memo(({ is_editable, tags, onChange, onTagClick }) => { + return ( + + ); +}); export { NodeTags }; diff --git a/src/components/node/NodeTags/placeholder.tsx b/src/components/node/NodeTags/placeholder.tsx deleted file mode 100644 index f5b015b4..00000000 --- a/src/components/node/NodeTags/placeholder.tsx +++ /dev/null @@ -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 = memo( - ({ is_editable, tags, onChange }) => ( - - ) -); - -export { NodeTagsPlaceholder }; diff --git a/src/components/node/NodeTagsPlaceholder/index.tsx b/src/components/node/NodeTagsPlaceholder/index.tsx new file mode 100644 index 00000000..4fb8e75d --- /dev/null +++ b/src/components/node/NodeTagsPlaceholder/index.tsx @@ -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 = memo(({ is_editable, tags, onChange }) => ( + +)); + +export { NodeTagsPlaceholder }; diff --git a/src/components/node/Tag/index.tsx b/src/components/node/Tag/index.tsx deleted file mode 100644 index fd8e4896..00000000 --- a/src/components/node/Tag/index.tsx +++ /dev/null @@ -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) => { - if (tag.title.substr(0, 1) === '/') return 'green'; - - return ''; -}; - -interface IProps { - tag: Partial; - - is_hoverable?: boolean; - is_editing?: boolean; - - onInput?: ChangeEventHandler; - onKeyUp?: KeyboardEventHandler; - onBlur?: FocusEventHandler; -} - -const Tag: FC = ({ tag, is_hoverable, is_editing, onInput, onKeyUp, onBlur }) => ( -
-
-
{tag.title}
- - {onInput && ( - - )} -
-); - -export { Tag }; diff --git a/src/components/node/Tags/index.tsx b/src/components/node/Tags/index.tsx deleted file mode 100644 index eafb933f..00000000 --- a/src/components/node/Tags/index.tsx +++ /dev/null @@ -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 & { - tags: Partial[]; - is_editable?: boolean; - onTagsChange?: (tags: string[]) => void; -}; - -export const Tags: FC = ({ 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) => { - 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 ( - - {catTags.map(tag => ( - - ))} - - {ordinaryTags.map(tag => ( - - ))} - - {data.map(tag => ( - - ))} - - {is_editable && ( - - )} - - ); -}; diff --git a/src/components/sidebar/TagSidebarList/index.tsx b/src/components/sidebar/TagSidebarList/index.tsx new file mode 100644 index 00000000..b4c11027 --- /dev/null +++ b/src/components/sidebar/TagSidebarList/index.tsx @@ -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 = ({ nodes }) => ( +
+ {nodes.map(node => ( + + ))} +
+); + +export { TagSidebarList }; diff --git a/src/components/sidebar/TagSidebarList/styles.module.scss b/src/components/sidebar/TagSidebarList/styles.module.scss new file mode 100644 index 00000000..868192b4 --- /dev/null +++ b/src/components/sidebar/TagSidebarList/styles.module.scss @@ -0,0 +1,4 @@ +.list { + flex: 1; + flex-direction: column; +} diff --git a/src/components/tags/Tag/index.tsx b/src/components/tags/Tag/index.tsx new file mode 100644 index 00000000..8207e65a --- /dev/null +++ b/src/components/tags/Tag/index.tsx @@ -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) => { + if (tag.title.substr(0, 1) === '/') return 'green'; + + return ''; +}; + +interface IProps { + tag: Partial; + size?: 'normal' | 'big'; + + is_hoverable?: boolean; + is_editing?: boolean; + + onBlur?: FocusEventHandler; + onClick?: (tag: Partial) => void; +} + +const Tag: FC = ({ tag, is_hoverable, is_editing, size = 'normal', onBlur, onClick }) => { + const onClickHandler = useCallback(() => { + if (!onClick) return; + onClick(tag); + }, [tag, onClick]); + + return ( + + ); +}; + +export { Tag }; diff --git a/src/components/tags/Tag/styles.scss b/src/components/tags/Tag/styles.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/components/tags/TagAutocomplete/index.tsx b/src/components/tags/TagAutocomplete/index.tsx new file mode 100644 index 00000000..87ba84f7 --- /dev/null +++ b/src/components/tags/TagAutocomplete/index.tsx @@ -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 mapDispatchToProps & { + exclude: string[]; + input: HTMLInputElement; + onSelect: (val: string) => void; + search: string; + }; + +const TagAutocompleteUnconnected: FC = ({ + 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(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 ( +
+
+ + + {categories.map((item, i) => ( + + ))} + + {tags.map((item, i) => ( + + ))} +
+
+ ); +}; + +const TagAutocomplete = connect(mapStateToProps, mapDispatchToProps)(TagAutocompleteUnconnected); + +export { TagAutocomplete }; diff --git a/src/components/tags/TagAutocomplete/styles.module.scss b/src/components/tags/TagAutocomplete/styles.module.scss new file mode 100644 index 00000000..abdd204f --- /dev/null +++ b/src/components/tags/TagAutocomplete/styles.module.scss @@ -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; +} diff --git a/src/components/tags/TagAutocompleteRow/index.tsx b/src/components/tags/TagAutocompleteRow/index.tsx new file mode 100644 index 00000000..0340bd3d --- /dev/null +++ b/src/components/tags/TagAutocompleteRow/index.tsx @@ -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 = ({ selected, type, title }) => ( +
+ + {title} +
+); + +export { TagAutocompleteRow }; diff --git a/src/components/tags/TagAutocompleteRow/styles.module.scss b/src/components/tags/TagAutocompleteRow/styles.module.scss new file mode 100644 index 00000000..51efd1b3 --- /dev/null +++ b/src/components/tags/TagAutocompleteRow/styles.module.scss @@ -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; + } +} diff --git a/src/components/tags/TagInput/index.tsx b/src/components/tags/TagInput/index.tsx new file mode 100644 index 00000000..3d8c5b37 --- /dev/null +++ b/src/components/tags/TagInput/index.tsx @@ -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 = ({ exclude, onAppend, onClearTag, onSubmit }) => { + const [focused, setFocused] = useState(false); + const [input, setInput] = useState(''); + const ref = useRef(null); + const wrapper = useRef(null); + + const onInput = useCallback( + ({ target: { value } }: ChangeEvent) => { + 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 ( +
+ + + + + {onInput && focused && input?.length > 0 && ( + + )} +
+ ); +}; + +export { TagInput }; diff --git a/src/components/tags/TagInput/styles.module.scss b/src/components/tags/TagInput/styles.module.scss new file mode 100644 index 00000000..c5ef4d75 --- /dev/null +++ b/src/components/tags/TagInput/styles.module.scss @@ -0,0 +1,4 @@ +.wrap { + position: relative; + z-index: 20; +} diff --git a/src/components/tags/TagWrapper/index.tsx b/src/components/tags/TagWrapper/index.tsx new file mode 100644 index 00000000..2eb0f28e --- /dev/null +++ b/src/components/tags/TagWrapper/index.tsx @@ -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 = ({ + children, + feature, + size, + is_hoverable, + is_editing, + has_input, + onClick, + title = '', +}) => ( +
+
+
{title}
+ {children} +
+); + +export { TagWrapper }; diff --git a/src/components/node/Tag/styles.scss b/src/components/tags/TagWrapper/styles.module.scss similarity index 81% rename from src/components/node/Tag/styles.scss rename to src/components/tags/TagWrapper/styles.module.scss index d5280c5c..a16a6358 100644 --- a/src/components/node/Tag/styles.scss +++ b/src/components/tags/TagWrapper/styles.module.scss @@ -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; } + diff --git a/src/components/tags/Tags/index.tsx b/src/components/tags/Tags/index.tsx new file mode 100644 index 00000000..8feec917 --- /dev/null +++ b/src/components/tags/Tags/index.tsx @@ -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 & { + tags: Partial[]; + is_editable?: boolean; + onTagsChange?: (tags: string[]) => void; + onTagClick?: (tag: Partial) => void; +}; + +export const Tags: FC = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => { + const [data, setData] = useState([]); + + 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 ( + + {catTags.map(tag => ( + + ))} + + {ordinaryTags.map(tag => ( + + ))} + + {data.map(title => ( + + ))} + + {is_editable && ( + + )} + + ); +}; diff --git a/src/constants/api.ts b/src/constants/api.ts index 991fe958..4c23cf31 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -1,4 +1,4 @@ -import { INode, IComment } from '~/redux/types'; +import { IComment, INode } from '~/redux/types'; import { ISocialProvider } from '~/redux/auth/types'; export const API = { @@ -46,4 +46,8 @@ export const API = { BORIS: { GET_BACKEND_STATS: '/stats/', }, + TAG: { + NODES: `/tag/nodes`, + AUTOCOMPLETE: `/tag/autocomplete`, + }, }; diff --git a/src/constants/urls.ts b/src/constants/urls.ts index 055bc214..606e6407 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -13,6 +13,7 @@ export const URLS = { BACKEND_DOWN: '/oopsie', }, NODE_URL: (id: number | string) => `/post${id}`, + NODE_TAG_URL: (id: number, tagName: string) => `/post${id}/tag/${tagName}`, PROFILE: (username: string) => `/~${username}`, PROFILE_PAGE: (username: string) => `/profile/${username}`, }; diff --git a/src/containers/main/SidebarRouter/index.tsx b/src/containers/main/SidebarRouter/index.tsx new file mode 100644 index 00000000..0bf35ca6 --- /dev/null +++ b/src/containers/main/SidebarRouter/index.tsx @@ -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 = ({ prefix = '' }) => ( + + + +); + +export { SidebarRouter }; diff --git a/src/containers/node/NodeLayout/index.tsx b/src/containers/node/NodeLayout/index.tsx index 1e6d7ec0..a829eeab 100644 --- a/src/containers/node/NodeLayout/index.tsx +++ b/src/containers/node/NodeLayout/index.tsx @@ -1,5 +1,5 @@ -import React, { FC, createElement, useEffect, useCallback, useState, useMemo, memo } from 'react'; -import { RouteComponentProps } from 'react-router'; +import React, { createElement, FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { RouteComponentProps, useHistory } from 'react-router'; import { connect } from 'react-redux'; import { canEditNode, canLikeNode, canStarNode } from '~/utils/node'; import { selectNode } from '~/redux/node/selectors'; @@ -12,12 +12,7 @@ import { NodeNoComments } from '~/components/node/NodeNoComments'; import { NodeRelated } from '~/components/node/NodeRelated'; import { NodeComments } from '~/components/node/NodeComments'; import { NodeTags } from '~/components/node/NodeTags'; -import { - NODE_COMPONENTS, - NODE_INLINES, - NODE_HEADS, - INodeComponentProps, -} from '~/redux/node/constants'; +import { INodeComponentProps, NODE_COMPONENTS, NODE_HEADS, NODE_INLINES, } from '~/redux/node/constants'; import { selectUser } from '~/redux/auth/selectors'; import pick from 'ramda/es/pick'; import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder'; @@ -25,12 +20,16 @@ import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; import { NodeCommentForm } from '~/components/node/NodeCommentForm'; import { Sticky } from '~/components/containers/Sticky'; import { Footer } from '~/components/main/Footer'; +import { Link } from 'react-router-dom'; import * as styles from './styles.scss'; import * as NODE_ACTIONS from '~/redux/node/actions'; import * as MODAL_ACTIONS from '~/redux/modal/actions'; import { IState } from '~/redux/store'; 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) => ({ node: selectNode(state), @@ -86,6 +85,7 @@ const NodeLayoutUnconnected: FC = memo( modalShowPhotoswipe, }) => { const [layout, setLayout] = useState({}); + const history = useHistory(); const updateLayout = useCallback(() => setLayout({}), []); @@ -101,6 +101,13 @@ const NodeLayoutUnconnected: FC = memo( [node, nodeUpdateTags] ); + const onTagClick = useCallback( + (tag: Partial) => { + 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_like = useMemo(() => canLikeNode(node, user), [node, user]); const can_star = useMemo(() => canStarNode(node, user), [node, user]); @@ -197,6 +204,7 @@ const NodeLayoutUnconnected: FC = memo( is_editable={is_user} tags={node.tags} onChange={onTagsChange} + onTagClick={onTagClick} /> )} @@ -209,7 +217,11 @@ const NodeLayoutUnconnected: FC = memo( .filter(album => related.albums[album].length > 0) .map(album => ( + {album} + + } items={related.albums[album]} key={album} /> @@ -231,6 +243,8 @@ const NodeLayoutUnconnected: FC = memo(