diff --git a/package.json b/package.json index ddd058ae..beea8443 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@tippy.js/react": "^3.1.1", + "@types/react-router-dom": "^5.1.7", "autosize": "^4.0.2", "axios": "^0.21.1", "body-scroll-lock": "^2.6.4", @@ -29,6 +31,7 @@ "react-router-dom": "^5.1.2", "react-scripts": "3.4.4", "react-sortable-hoc": "^1.11", + "react-sticky-box": "^0.9.3", "redux": "^4.0.1", "redux-persist": "^5.10.0", "redux-saga": "^1.1.1", @@ -71,8 +74,8 @@ "@types/node": "^11.13.22", "@types/ramda": "^0.26.33", "@types/react-redux": "^7.1.11", - "@types/yup": "^0.29.11", "@types/swiper": "^5.4.2", + "@types/yup": "^0.29.11", "craco-alias": "^2.1.1", "craco-fast-refresh": "^1.0.2", "prettier": "^1.18.2" diff --git a/src/components/boris/BorisComments/index.tsx b/src/components/boris/BorisComments/index.tsx new file mode 100644 index 00000000..b4350c44 --- /dev/null +++ b/src/components/boris/BorisComments/index.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; +import styles from './styles.module.scss'; +import { Group } from '~/components/containers/Group'; +import { NodeCommentForm } from '~/components/node/NodeCommentForm'; +import { NodeNoComments } from '~/components/node/NodeNoComments'; +import { NodeComments } from '~/components/node/NodeComments'; +import { Footer } from '~/components/main/Footer'; +import { Card } from '~/components/containers/Card'; +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { selectAuthUser } from '~/redux/auth/selectors'; +import { IComment, INode } from '~/redux/types'; + +interface IProps { + isLoadingComments: boolean; + commentCount: number; + node: INode; + comments: IComment[]; +} + +const BorisComments: FC<IProps> = ({ isLoadingComments, node, commentCount, comments }) => { + const user = useShallowSelect(selectAuthUser); + + return ( + <> + <Group className={styles.grid}> + {user.is_user && <NodeCommentForm isBefore nodeId={node.id} />} + + {isLoadingComments ? ( + <NodeNoComments is_loading count={7} /> + ) : ( + <NodeComments comments={comments} count={commentCount} user={user} order="ASC" /> + )} + </Group> + + <Footer /> + </> + ); +}; + +export { BorisComments }; diff --git a/src/components/boris/BorisComments/styles.module.scss b/src/components/boris/BorisComments/styles.module.scss new file mode 100644 index 00000000..7662aac0 --- /dev/null +++ b/src/components/boris/BorisComments/styles.module.scss @@ -0,0 +1,18 @@ +@import "~/styles/variables.scss"; + +.content { + flex: 4; + z-index: 2; + border-radius: $radius; + padding: $gap; + background: $node_bg; + box-shadow: inset transparentize(mix($wisegreen, white, 60%), 0.6) 0 1px; + + @include desktop { + flex: 2.5; + } + + @media(max-width: 1024px) { + flex: 2; + } +} diff --git a/src/components/boris/BorisStatsGit/index.tsx b/src/components/boris/BorisStatsGit/index.tsx index d81ac721..030699a3 100644 --- a/src/components/boris/BorisStatsGit/index.tsx +++ b/src/components/boris/BorisStatsGit/index.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { IBorisState } from '~/redux/boris/reducer'; import styles from './styles.module.scss'; import { Placeholder } from '~/components/placeholders/Placeholder'; @@ -9,7 +9,17 @@ interface IProps { } const BorisStatsGit: FC<IProps> = ({ stats }) => { - if (!stats.git.length) return null; + if (!stats.issues.length) return null; + + const open = useMemo( + () => stats.issues.filter(el => !el.pull_request && el.state === 'open').slice(0, 5), + [stats.issues] + ); + + const closed = useMemo( + () => stats.issues.filter(el => !el.pull_request && el.state === 'closed').slice(0, 5), + [stats.issues] + ); if (stats.is_loading) { return ( @@ -35,12 +45,13 @@ const BorisStatsGit: FC<IProps> = ({ stats }) => { <img src="https://jenkins.vault48.org/api/badges/muerwre/vault-golang/status.svg" /> </div> - {stats.git - .filter(data => data.commit && data.timestamp && data.subject) - .slice(0, 5) - .map(data => ( - <BorisStatsGitCard data={data} key={data.commit} /> - ))} + {open.map(data => ( + <BorisStatsGitCard data={data} key={data.id} /> + ))} + + {closed.map(data => ( + <BorisStatsGitCard data={data} key={data.id} /> + ))} </div> ); }; diff --git a/src/components/boris/BorisStatsGitCard/index.tsx b/src/components/boris/BorisStatsGitCard/index.tsx index f393b710..20f4d469 100644 --- a/src/components/boris/BorisStatsGitCard/index.tsx +++ b/src/components/boris/BorisStatsGitCard/index.tsx @@ -1,22 +1,33 @@ -import React, { FC } from 'react'; -import { IStatGitRow } from '~/redux/boris/reducer'; +import React, { FC, useMemo } from 'react'; import styles from './styles.module.scss'; import { getPrettyDate } from '~/utils/dom'; +import { IGithubIssue } from '~/redux/boris/types'; +import classNames from 'classnames'; interface IProps { - data: Partial<IStatGitRow>; + data: IGithubIssue; } -const BorisStatsGitCard: FC<IProps> = ({ data: { timestamp, subject } }) => { - if (!subject || !timestamp) return null; +const stateLabels: Record<IGithubIssue['state'], string> = { + open: 'Ожидает', + closed: 'Сделано', +}; + +const BorisStatsGitCard: FC<IProps> = ({ data: { created_at, title, html_url, state } }) => { + if (!title || !created_at) return null; + + const date = useMemo(() => getPrettyDate(created_at), [created_at]); return ( <div className={styles.wrap}> <div className={styles.time}> - {getPrettyDate(new Date(parseInt(`${timestamp}000`)).toISOString())} + <span className={classNames(styles.icon, styles[state])}>{stateLabels[state]}</span> + {date} </div> - <div className={styles.subject}>{subject}</div> + <a className={styles.subject} href={html_url} target="_blank"> + {title} + </a> </div> ); }; diff --git a/src/components/boris/BorisStatsGitCard/styles.module.scss b/src/components/boris/BorisStatsGitCard/styles.module.scss index 37bd0b23..eaad031a 100644 --- a/src/components/boris/BorisStatsGitCard/styles.module.scss +++ b/src/components/boris/BorisStatsGitCard/styles.module.scss @@ -12,10 +12,28 @@ .time { font: $font_12_regular; line-height: 17px; - opacity: 0.3; + color: transparentize(white, 0.7) } .subject { font: $font_14_regular; word-break: break-word; + text-decoration: none; + color: inherit; +} + +.icon { + font: $font_10_semibold; + margin-right: 5px; + border-radius: 2px; + padding: 2px 0; + text-transform: uppercase; + + &.open { + color: $red; + } + + &.closed { + color: $green; + } } diff --git a/src/components/boris/BorisSuperpowers/index.tsx b/src/components/boris/BorisSuperpowers/index.tsx new file mode 100644 index 00000000..cf2530cb --- /dev/null +++ b/src/components/boris/BorisSuperpowers/index.tsx @@ -0,0 +1,37 @@ +import React, { FC, useCallback } from 'react'; +import styles from './styles.module.scss'; +import { Toggle } from '~/components/input/Toggle'; + +interface IProps { + active?: boolean; + onChange?: (val: boolean) => void; +} + +const BorisSuperpowers: FC<IProps> = ({ active, onChange }) => { + const onToggle = useCallback(() => { + if (!onChange) { + return; + } + + onChange(!active); + }, [onChange, active]); + + return ( + <div className={styles.wrap}> + <div className={styles.toggle}> + <Toggle value={active} handler={onChange} color="primary" /> + </div> + + <div className={styles.left} onClick={onToggle}> + <div className={styles.title}>Суперспособности</div> + {active ? ( + <div className={styles.subtitle}>Ты видишь всё, что скрыто</div> + ) : ( + <div className={styles.subtitle}>Включи, чтобы видеть будущее</div> + )} + </div> + </div> + ); +}; + +export { BorisSuperpowers }; diff --git a/src/components/boris/BorisSuperpowers/styles.module.scss b/src/components/boris/BorisSuperpowers/styles.module.scss new file mode 100644 index 00000000..473572e7 --- /dev/null +++ b/src/components/boris/BorisSuperpowers/styles.module.scss @@ -0,0 +1,20 @@ +@import "~/styles/variables"; + +.wrap { + display: grid; + grid-template-columns: auto 1fr; + column-gap: $gap; + align-items: center; + cursor: pointer; +} + +.title { + font: $font_14_semibold; + color: white; + text-transform: uppercase; +} + +.subtitle { + font: $font_12_regular; + color: transparentize(white, 0.5); +} diff --git a/src/components/boris/BorisUIDemo/index.tsx b/src/components/boris/BorisUIDemo/index.tsx new file mode 100644 index 00000000..bba0c86a --- /dev/null +++ b/src/components/boris/BorisUIDemo/index.tsx @@ -0,0 +1,51 @@ +import React, { FC } from 'react'; +import { Card } from '~/components/containers/Card'; +import styles from './styles.module.scss'; +import markdown from '~/styles/common/markdown.module.scss'; +import { Group } from '~/components/containers/Group'; +import { Button } from '~/components/input/Button'; + +interface IProps {} + +const BorisUIDemo: FC<IProps> = () => ( + <Card className={styles.card}> + <div className={markdown.wrapper}> + <h1>UI</h1> + <p> + Простая демонстрация элементов интерфейса. Используется, в основном, как подсказка при + разработке + </p> + + <h2>Кнопки</h2> + + <h4>Цвета</h4> + + <Group horizontal className={styles.sample}> + <Button>Primary</Button> + <Button color="secondary">Secondary</Button> + <Button color="outline">Outline</Button> + <Button color="gray">Gray</Button> + <Button color="link">Link</Button> + </Group> + + <h4>Размеры</h4> + + <Group horizontal className={styles.sample}> + <Button size="micro">Micro</Button> + <Button size="mini">Mini</Button> + <Button size="normal">Normal</Button> + <Button size="big">Big</Button> + <Button size="giant">Giant</Button> + </Group> + + <h4>Варианты</h4> + <Group horizontal className={styles.sample}> + <Button iconRight="check">iconRight</Button> + <Button iconLeft="send">iconLeft</Button> + <Button round>Round</Button> + </Group> + </div> + </Card> +); + +export { BorisUIDemo }; diff --git a/src/components/boris/BorisUIDemo/styles.module.scss b/src/components/boris/BorisUIDemo/styles.module.scss new file mode 100644 index 00000000..4e04d429 --- /dev/null +++ b/src/components/boris/BorisUIDemo/styles.module.scss @@ -0,0 +1,14 @@ +@import "~/styles/variables.scss"; + +.card { + flex: 3; + align-self: stretch; + position: relative; + z-index: 1; + padding: 20px 30px; + background-color: lighten($content_bg, 4%); +} + +.sample { + flex-wrap: wrap; +} diff --git a/src/components/boris/Superpower/index.tsx b/src/components/boris/Superpower/index.tsx new file mode 100644 index 00000000..01d08f75 --- /dev/null +++ b/src/components/boris/Superpower/index.tsx @@ -0,0 +1,16 @@ +import React, { FC, memo } from 'react'; +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { selectAuthIsTester, selectUser } from '~/redux/auth/selectors'; + +interface IProps {} + +const Superpower: FC<IProps> = ({ children }) => { + const user = useShallowSelect(selectUser); + const is_tester = useShallowSelect(selectAuthIsTester); + + if (!user.is_user || !is_tester) return null; + + return <>{children}</>; +}; + +export { Superpower }; diff --git a/src/components/comment/CommentEmbedBlock/index.tsx b/src/components/comment/CommentEmbedBlock/index.tsx index 77a374d2..e07b7ecf 100644 --- a/src/components/comment/CommentEmbedBlock/index.tsx +++ b/src/components/comment/CommentEmbedBlock/index.tsx @@ -30,6 +30,8 @@ const CommentEmbedBlockUnconnected: FC<Props> = memo( return (match && match[1]) || ''; }, [block.content]); + const url = useMemo(() => `https://youtube.com/watch?v=${id}`, [id]); + const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]); useEffect(() => { @@ -47,7 +49,7 @@ const CommentEmbedBlockUnconnected: FC<Props> = memo( return ( <div className={styles.embed}> - <a href={id[0]} target="_blank" /> + <a href={url} target="_blank" /> <div className={styles.preview}> <div style={{ backgroundImage: `url("${preview}")` }}> diff --git a/src/components/comment/CommentForm/index.tsx b/src/components/comment/CommentForm/index.tsx index b644509d..d1a9337e 100644 --- a/src/components/comment/CommentForm/index.tsx +++ b/src/components/comment/CommentForm/index.tsx @@ -14,7 +14,7 @@ import { EMPTY_COMMENT } from '~/redux/node/constants'; import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone'; import styles from './styles.module.scss'; import { ERROR_LITERAL } from '~/constants/errors'; -import { Group } from '~/components/containers/Group'; +import { useInputPasteUpload } from '~/utils/hooks/useInputPasteUpload'; interface IProps { comment?: IComment; @@ -47,6 +47,7 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => { }, [formik]); const error = formik.status || formik.errors.text; + useInputPasteUpload(textarea, uploader.uploadFiles); return ( <CommentFormDropzone onUpload={uploader.uploadFiles}> @@ -65,34 +66,40 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => { <CommentFormAttaches /> - <Group horizontal className={styles.buttons}> - <CommentFormAttachButtons onUpload={uploader.uploadFiles} /> + <div className={styles.buttons}> + <div className={styles.buttons_attach}> + <CommentFormAttachButtons onUpload={uploader.uploadFiles} /> + </div> - {!!textarea && ( - <CommentFormFormatButtons - element={textarea} - handler={formik.handleChange('text')} - /> - )} + <div className={styles.buttons_format}> + {!!textarea && ( + <CommentFormFormatButtons + element={textarea} + handler={formik.handleChange('text')} + /> + )} + </div> - {isLoading && <LoaderCircle size={20} />} + <div className={styles.buttons_submit}> + {isLoading && <LoaderCircle size={20} />} - {isEditing && ( - <Button size="small" color="link" type="button" onClick={onCancelEdit}> - Отмена + {isEditing && ( + <Button size="small" color="link" type="button" onClick={onCancelEdit}> + Отмена + </Button> + )} + + <Button + type="submit" + size="small" + color="gray" + iconRight={!isEditing ? 'enter' : 'check'} + disabled={isLoading} + > + {!isEditing ? 'Сказать' : 'Сохранить'} </Button> - )} - - <Button - type="submit" - size="small" - color="gray" - iconRight={!isEditing ? 'enter' : 'check'} - disabled={isLoading} - > - {!isEditing ? 'Сказать' : 'Сохранить'} - </Button> - </Group> + </div> + </div> </FileUploaderProvider> </FormikProvider> </form> diff --git a/src/components/comment/CommentForm/styles.module.scss b/src/components/comment/CommentForm/styles.module.scss index 12798bf2..fb629c05 100644 --- a/src/components/comment/CommentForm/styles.module.scss +++ b/src/components/comment/CommentForm/styles.module.scss @@ -21,13 +21,42 @@ position: relative; z-index: 1; - display: flex; - flex-direction: row; + display: grid; background: transparentize(black, 0.8); padding: $gap / 2; border-radius: 0 0 $radius $radius; flex-wrap: wrap; + column-gap: $gap; + grid-template-columns: auto 1fr auto; + grid-template-rows: 1fr; + grid-template-areas: "attach format submit"; + @media(max-width: 470px) { + padding: $gap; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr 1fr; + grid-template-areas: + "attach format" + "submit submit"; + row-gap: $gap; + } + + &_attach { + grid-area: attach; + } + + &_format { + grid-area: format; + } + + &_submit { + grid-area: submit; + display: grid; + grid-auto-flow: column; + align-items: flex-end; + justify-content: flex-end; + column-gap: $gap / 2; + } } .uploads { diff --git a/src/components/comment/CommentFormFormatButtons/styles.module.scss b/src/components/comment/CommentFormFormatButtons/styles.module.scss index e63a68c1..d07205e9 100644 --- a/src/components/comment/CommentFormFormatButtons/styles.module.scss +++ b/src/components/comment/CommentFormFormatButtons/styles.module.scss @@ -2,11 +2,8 @@ .wrap { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; height: 32px; flex: 1; - - @media(max-width: 480px) { - display: none; - } + width: 100%; } diff --git a/src/components/containers/CommentWrapper/styles.module.scss b/src/components/containers/CommentWrapper/styles.module.scss index c1f36ae5..6587b197 100644 --- a/src/components/containers/CommentWrapper/styles.module.scss +++ b/src/components/containers/CommentWrapper/styles.module.scss @@ -33,7 +33,6 @@ @include tablet { :global(.comment-author) { display: none !important; - color: red; } } } diff --git a/src/components/containers/Sticky/index.tsx b/src/components/containers/Sticky/index.tsx index f3e817d5..55288c87 100644 --- a/src/components/containers/Sticky/index.tsx +++ b/src/components/containers/Sticky/index.tsx @@ -1,40 +1,15 @@ -import React, { DetailsHTMLAttributes, FC, useEffect, useRef } from 'react'; -import styles from './styles.module.scss'; -import StickySidebar from 'sticky-sidebar'; -import classnames from 'classnames'; -import ResizeSensor from 'resize-sensor'; +import React, { DetailsHTMLAttributes, FC } from 'react'; +import StickyBox from 'react-sticky-box/dist/esnext'; -interface IProps extends DetailsHTMLAttributes<HTMLDivElement> {} - -(window as any).StickySidebar = StickySidebar; -(window as any).ResizeSensor = ResizeSensor; - -const Sticky: FC<IProps> = ({ children }) => { - const ref = useRef(null); - const sb = useRef<StickySidebar>(null); - - useEffect(() => { - if (!ref.current) return; - - sb.current = new StickySidebar(ref.current, { - resizeSensor: true, - topSpacing: 72, - bottomSpacing: 10, - }); - - return () => sb.current?.destroy(); - }, [ref.current, sb.current, children]); - - if (sb) { - sb.current?.updateSticky(); - } +interface IProps extends DetailsHTMLAttributes<HTMLDivElement> { + offsetTop?: number; +} +const Sticky: FC<IProps> = ({ children, offsetTop = 65 }) => { return ( - <div className={classnames(styles.wrap, 'sidebar_container')}> - <div className="sidebar" ref={ref}> - <div className={classnames(styles.sticky, 'sidebar__inner')}>{children}</div> - </div> - </div> + <StickyBox offsetTop={offsetTop} offsetBottom={10}> + {children} + </StickyBox> ); }; diff --git a/src/components/containers/Sticky/styles.module.scss b/src/components/containers/Sticky/styles.module.scss deleted file mode 100644 index eb680535..00000000 --- a/src/components/containers/Sticky/styles.module.scss +++ /dev/null @@ -1,17 +0,0 @@ -@import "src/styles/variables"; - -.wrap { - height: 100%; - width: 100%; - position: relative; - - :global(.sidebar) { - will-change: min-height; - } - - :global(.sidebar__inner) { - transform: translate(0, 0); /* For browsers don't support translate3d. */ - transform: translate3d(0, 0, 0); - will-change: position, transform; - } -} diff --git a/src/components/dialogs/Tab/index.tsx b/src/components/dialogs/Tab/index.tsx new file mode 100644 index 00000000..20b49d77 --- /dev/null +++ b/src/components/dialogs/Tab/index.tsx @@ -0,0 +1,16 @@ +import React, { FC, MouseEventHandler } from 'react'; +import classNames from 'classnames'; +import styles from './styles.module.scss'; + +interface IProps { + active?: boolean; + onClick?: MouseEventHandler<any>; +} + +const Tab: FC<IProps> = ({ active, onClick, children }) => ( + <div className={classNames(styles.tab, { [styles.active]: active })} onClick={onClick}> + {children} + </div> +); + +export { Tab }; diff --git a/src/components/dialogs/Tab/styles.module.scss b/src/components/dialogs/Tab/styles.module.scss new file mode 100644 index 00000000..be3d83e4 --- /dev/null +++ b/src/components/dialogs/Tab/styles.module.scss @@ -0,0 +1,20 @@ +@import "src/styles/variables"; + +.tab { + @include outer_shadow(); + + padding: $gap; + margin-right: $gap; + border-radius: $radius $radius 0 0; + font: $font_14_semibold; + text-transform: uppercase; + cursor: pointer; + background-color: $content_bg; + color: white; + text-decoration: none; + border: none; + + &.active { + background: lighten($content_bg, 4%); + } +} diff --git a/src/components/dialogs/Tabs/index.tsx b/src/components/dialogs/Tabs/index.tsx new file mode 100644 index 00000000..f7ca42fd --- /dev/null +++ b/src/components/dialogs/Tabs/index.tsx @@ -0,0 +1,12 @@ +import React, { FC, useCallback } from 'react'; +import styles from './styles.module.scss'; +import classNames from 'classnames'; +import { IAuthState } from '~/redux/auth/types'; + +interface IProps {} + +const Tabs: FC<IProps> = ({ children }) => { + return <div className={styles.wrap}>{children}</div>; +}; + +export { Tabs }; diff --git a/src/components/dialogs/Tabs/styles.module.scss b/src/components/dialogs/Tabs/styles.module.scss new file mode 100644 index 00000000..3e915328 --- /dev/null +++ b/src/components/dialogs/Tabs/styles.module.scss @@ -0,0 +1,8 @@ +@import "src/styles/variables"; + +.wrap { + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: 0 $gap / 2; +} diff --git a/src/components/editors/EditorImageUploadButton/index.tsx b/src/components/editors/EditorImageUploadButton/index.tsx index 68504973..f333cf9d 100644 --- a/src/components/editors/EditorImageUploadButton/index.tsx +++ b/src/components/editors/EditorImageUploadButton/index.tsx @@ -1,6 +1,5 @@ import React, { FC } from 'react'; import { EditorUploadButton } from '~/components/editors/EditorUploadButton'; -import { INode } from '~/redux/types'; import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { IEditorComponentProps } from '~/redux/node/types'; diff --git a/src/components/editors/EditorPanel/styles.module.scss b/src/components/editors/EditorPanel/styles.module.scss index a58338b2..5bda9666 100644 --- a/src/components/editors/EditorPanel/styles.module.scss +++ b/src/components/editors/EditorPanel/styles.module.scss @@ -13,11 +13,12 @@ flex-direction: row; & > * { - margin: 0 $gap; + margin: 0 $gap / 2; &:first-child { margin-left: 0; } + &:last-child { margin-right: 0; } diff --git a/src/components/editors/EditorPublicSwitch/index.tsx b/src/components/editors/EditorPublicSwitch/index.tsx new file mode 100644 index 00000000..71c1fef2 --- /dev/null +++ b/src/components/editors/EditorPublicSwitch/index.tsx @@ -0,0 +1,43 @@ +import React, { FC, useCallback } from 'react'; +import { IEditorComponentProps } from '~/redux/node/types'; +import { Button } from '~/components/input/Button'; +import { Icon } from '~/components/input/Icon'; +import styles from './styles.module.scss'; +import { Superpower } from '~/components/boris/Superpower'; + +interface IProps extends IEditorComponentProps {} + +const EditorPublicSwitch: FC<IProps> = ({ data, setData }) => { + const onChange = useCallback(() => setData({ ...data, is_promoted: !data.is_promoted }), [ + data, + setData, + ]); + + return ( + <Superpower> + <Button + color={data.is_promoted ? 'primary' : 'lab'} + type="button" + size="giant" + label={ + data.is_promoted + ? 'Доступно всем на главной странице' + : 'Видно только сотрудникам в лаборатории' + } + onClick={onChange} + className={styles.button} + round + > + {data.is_promoted ? ( + <Icon icon="waves" size={24} /> + ) : ( + <div className={styles.lab_wrapper}> + <Icon icon="lab" size={24} /> + </div> + )} + </Button> + </Superpower> + ); +}; + +export { EditorPublicSwitch }; diff --git a/src/components/editors/EditorPublicSwitch/styles.module.scss b/src/components/editors/EditorPublicSwitch/styles.module.scss new file mode 100644 index 00000000..95aa2412 --- /dev/null +++ b/src/components/editors/EditorPublicSwitch/styles.module.scss @@ -0,0 +1,63 @@ +@import "src/styles/variables"; + +@keyframes move_1 { + 0% { + transform: scale(0) translate(0, 0); + opacity: 0; + } + + 50% { + transform: scale(1) translate(5px, -5px); + opacity: 1; + } + + 100% { + transform: scale(1.2) translate(-5px, -10px); + opacity: 0; + } +} + + +@keyframes move_2 { + 0% { + transform: scale(0) translate(0, 0); + opacity: 0; + } + + 50% { + transform: scale(1) translate(-5px, -5px); + opacity: 1; + } + + 100% { + transform: scale(1.6) translate(5px, -10px); + opacity: 0; + } +} + +.button { + +} + +.lab_wrapper { + position: relative; + bottom: -2px; + + .button:hover & { + &:before,&:after { + content: ' '; + position: absolute; + top: 1px; + left: 10px; + width: 2px; + height: 2px; + box-shadow: white 0 0 0 2px; + border-radius: 4px; + animation: move_1 0.5s infinite linear; + } + + &:after { + animation: move_2 0.5s -0.25s infinite linear; + } + } +} diff --git a/src/components/editors/EditorUploadButton/index.tsx b/src/components/editors/EditorUploadButton/index.tsx index 21b7cc50..e77d0804 100644 --- a/src/components/editors/EditorUploadButton/index.tsx +++ b/src/components/editors/EditorUploadButton/index.tsx @@ -1,15 +1,15 @@ import React, { FC, useCallback, useEffect } from 'react'; import styles from './styles.module.scss'; import { Icon } from '~/components/input/Icon'; -import { IFileWithUUID, INode, IFile } from '~/redux/types'; +import { IFile, IFileWithUUID } from '~/redux/types'; import uuid from 'uuid4'; import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants'; import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; -import { assocPath } from 'ramda'; -import { append } from 'ramda'; +import { append, assocPath } from 'ramda'; import { selectUploads } from '~/redux/uploads/selectors'; import { connect } from 'react-redux'; import { NODE_SETTINGS } from '~/redux/node/constants'; +import { IEditorComponentProps } from '~/redux/node/types'; const mapStateToProps = state => { const { statuses, files } = selectUploads(state); @@ -22,12 +22,7 @@ const mapDispatchToProps = { }; type IProps = ReturnType<typeof mapStateToProps> & - typeof mapDispatchToProps & { - data: INode; - setData: (val: INode) => void; - temp: string[]; - setTemp: (val: string[]) => void; - + typeof mapDispatchToProps & IEditorComponentProps & { accept?: string; icon?: string; type?: typeof UPLOAD_TYPES[keyof typeof UPLOAD_TYPES]; @@ -82,18 +77,6 @@ const EditorUploadButtonUnconnected: FC<IProps> = ({ [data, setData] ); - // const onDrop = useCallback( - // (event: React.DragEvent<HTMLDivElement>) => { - // event.preventDefault(); - - // if (!event.dataTransfer || !event.dataTransfer.files || !event.dataTransfer.files.length) - // return; - - // onUpload(Array.from(event.dataTransfer.files)); - // }, - // [onUpload] - // ); - useEffect(() => { window.addEventListener('dragover', eventPreventer, false); window.addEventListener('drop', eventPreventer, false); diff --git a/src/components/editors/EditorUploadButton/styles.module.scss b/src/components/editors/EditorUploadButton/styles.module.scss index 3ba89ad9..3347fd46 100644 --- a/src/components/editors/EditorUploadButton/styles.module.scss +++ b/src/components/editors/EditorUploadButton/styles.module.scss @@ -2,17 +2,10 @@ .wrap { @include outer_shadow(); + @include editor_round_button(); - width: $upload_button_height; - height: $upload_button_height; - border-radius: ($upload_button_height / 2) !important; - position: relative; - border-radius: $radius; - cursor: pointer; - // opacity: 0.7; transition: opacity 0.5s; background: $red_gradient; - // box-shadow: $content_bg 0 0 5px 10px; &:hover { opacity: 1; diff --git a/src/components/flow/FlowRecent/index.tsx b/src/components/flow/FlowRecent/index.tsx index 79bae2d4..53ac700e 100644 --- a/src/components/flow/FlowRecent/index.tsx +++ b/src/components/flow/FlowRecent/index.tsx @@ -11,7 +11,6 @@ const FlowRecent: FC<IProps> = ({ recent, updated }) => { return ( <> {updated && updated.map(node => <FlowRecentItem node={node} key={node.id} has_new />)} - {recent && recent.map(node => <FlowRecentItem node={node} key={node.id} />)} </> ); diff --git a/src/components/input/Button/index.tsx b/src/components/input/Button/index.tsx index 8c263770..e0f3965d 100644 --- a/src/components/input/Button/index.tsx +++ b/src/components/input/Button/index.tsx @@ -1,33 +1,24 @@ import classnames from 'classnames'; -import React, { - ButtonHTMLAttributes, - DetailedHTMLProps, - FC, - createElement, - memo, - useRef, -} from 'react'; +import React, { ButtonHTMLAttributes, DetailedHTMLProps, FC, memo, useMemo } from 'react'; import styles from './styles.module.scss'; import { Icon } from '~/components/input/Icon'; import { IIcon } from '~/redux/types'; -import { usePopper } from 'react-popper'; +import Tippy from '@tippy.js/react'; +import 'tippy.js/dist/tippy.css'; type IButtonProps = DetailedHTMLProps< ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement > & { size?: 'mini' | 'normal' | 'big' | 'giant' | 'micro' | 'small'; - color?: 'primary' | 'secondary' | 'outline' | 'link' | 'gray'; + color?: 'primary' | 'secondary' | 'outline' | 'link' | 'gray' | 'lab'; iconLeft?: IIcon; iconRight?: IIcon; - seamless?: boolean; - transparent?: boolean; title?: string; - non_submitting?: boolean; - is_loading?: boolean; stretchy?: boolean; iconOnly?: boolean; label?: string; + round?: boolean; }; const Button: FC<IButtonProps> = memo( @@ -38,56 +29,36 @@ const Button: FC<IButtonProps> = memo( iconLeft, iconRight, children, - seamless = false, - transparent = false, - non_submitting = false, - is_loading, title, stretchy, disabled, iconOnly, label, ref, + round, ...props }) => { - const tooltip = useRef<HTMLSpanElement | null>(null); - const pop = usePopper(tooltip?.current?.parentElement, tooltip.current, { - placement: 'top', - modifiers: [ - { - name: 'offset', - options: { - offset: [0, 5], - }, - }, - ], - }); - - return createElement( - seamless || non_submitting ? 'div' : 'button', - { - className: classnames(styles.button, className, styles[size], styles[color], { - seamless, - transparent, + const computedClassName = useMemo( + () => + classnames(styles.button, className, styles[size], styles[color], { disabled, - is_loading, stretchy, icon: ((iconLeft || iconRight) && !title && !children) || iconOnly, has_icon_left: !!iconLeft, has_icon_right: !!iconRight, + round, }), - ...props, - }, - [ - iconLeft && <Icon icon={iconLeft} size={20} key={0} className={styles.icon_left} />, - title ? <span>{title}</span> : children || null, - iconRight && <Icon icon={iconRight} size={20} key={2} className={styles.icon_right} />, - !!label && ( - <span ref={tooltip} className={styles.tooltip} style={pop.styles.popper} key="tooltip"> - {label} - </span> - ), - ] + [round, disabled, className, stretchy, iconLeft, iconRight, size, color] + ); + + return ( + <Tippy content={label || ''} enabled={!!label}> + <button className={computedClassName} {...props}> + {iconLeft && <Icon icon={iconLeft} size={20} key={0} className={styles.icon_left} />} + {!!title ? <span>{title}</span> : children} + {iconRight && <Icon icon={iconRight} size={20} key={2} className={styles.icon_right} />} + </button> + </Tippy> ); } ); diff --git a/src/components/input/Button/styles.module.scss b/src/components/input/Button/styles.module.scss index 676bb62d..0489151c 100644 --- a/src/components/input/Button/styles.module.scss +++ b/src/components/input/Button/styles.module.scss @@ -34,17 +34,14 @@ align-items: center; justify-content: center; - position: relative; - filter: grayscale(0); - transition: opacity 0.25s, filter 0.25s, box-shadow 0.25s; + transition: opacity 0.25s, filter 0.25s, box-shadow 0.25s, background-color 0.5s; opacity: 0.8; @include outer_shadow(); input { - color: red; position: absolute; top: 0; left: 0; @@ -80,30 +77,6 @@ } } - &:global(.seamless) { - background: transparent; - color: black; - box-shadow: none; - fill: black; - stroke: black; - padding: 0; - } - - &:global(.transparent) { - background: transparent; - color: white; - box-shadow: transparentize(black, 0.5) 0 0 4px; - padding: 0; - fill: black; - stroke: black; - } - - &:global(.red) { - fill: $red; - stroke: $red; - color: $red; - } - &:global(.stretchy) { flex: 1; } @@ -112,8 +85,6 @@ &:global(.grey) { background: transparentize(white, 0.9); color: white; - // background: lighten(white, 0.5); - // filter: grayscale(100%); } &:global(.disabled) { @@ -146,14 +117,6 @@ padding-right: $gap; } - &.primary { - background: $red_gradient; - } - - &.secondary { - background: $green_gradient; - } - &.outline { background: transparent; box-shadow: inset transparentize(white, 0.8) 0 0 0 2px; @@ -185,31 +148,60 @@ font: $font_12_semibold; padding: 0 15px; border-radius: $radius / 2; + + &:global(.round) { + border-radius: 10px; + } } + .mini { height: 28px; border-radius: $radius / 2; + + &:global(.round) { + border-radius: 14px; + } } + .small { height: 32px; - // border-radius: $radius / 2; svg { width: 24px; height: 24px; } + + &:global(.round) { + border-radius: 16px; + } } + .normal { height: 38px; + + &:global(.round) { + border-radius: 19px; + } } + .big { height: 40px; + + &:global(.round) { + border-radius: 20px; + } } + .giant { height: 50px; padding: 0 15px; min-width: 50px; + + &:global(.round) { + border-radius: 25px; + } } + .disabled { opacity: 0.5; } @@ -226,20 +218,14 @@ height: 20px; } -.tooltip { - padding: 5px 10px; - background-color: darken($content_bg, 4%); - z-index: 2; - border-radius: $input_radius; - text-transform: none; - opacity: 0; - pointer-events: none; - touch-action: none; - transition: opacity 0.1s; - border: 1px solid transparentize(white, 0.9); - - .button:hover & { - opacity: 1; - font: $font_14_semibold; - } +.primary { + background: $red; +} + +.secondary { + background: $wisegreen; +} + +.lab { + background: $blue; } diff --git a/src/components/input/Toggle/index.tsx b/src/components/input/Toggle/index.tsx new file mode 100644 index 00000000..f36ee358 --- /dev/null +++ b/src/components/input/Toggle/index.tsx @@ -0,0 +1,31 @@ +import React, { FC, useCallback } from 'react'; +import styles from './styles.module.scss'; +import classNames from 'classnames'; + +type ToggleColor = 'primary' | 'secondary' | 'lab' | 'danger'; + +interface IProps { + value?: boolean; + handler?: (val: boolean) => void; + color?: ToggleColor; +} + +const Toggle: FC<IProps> = ({ value, handler, color = 'primary' }) => { + const onClick = useCallback(() => { + if (!handler) { + return; + } + + handler(!value); + }, [value, handler]); + + return ( + <button + type="button" + className={classNames(styles.toggle, { [styles.active]: value }, styles[color])} + onClick={onClick} + /> + ); +}; + +export { Toggle }; diff --git a/src/components/input/Toggle/styles.module.scss b/src/components/input/Toggle/styles.module.scss new file mode 100644 index 00000000..00060eab --- /dev/null +++ b/src/components/input/Toggle/styles.module.scss @@ -0,0 +1,51 @@ +@import "~/styles/variables.scss"; + +.toggle { + height: 24px; + width: 48px; + flex: 0 0 48px; + border-radius: 12px; + background-color: transparentize(white, 0.9); + display: flex; + border: none; + outline: none; + cursor: pointer; + position: relative; + + &::after { + content: ' '; + position: absolute; + left: 3px; + top: 3px; + height: 18px; + width: 18px; + border-radius: 11px; + background-color: darken(white, 50%); + transform: translate(0, 0); + transition: transform 0.25s, color 0.25s, background-color; + } + + &.active { + + &::after { + transform: translate(24px, 0); + background-color: white; + } + + &.primary { + background-color: $wisegreen; + } + + &.secondary { + background-color: transparentize(white, 0.85); + } + + &.lab { + background-color: $blue; + } + + &.danger { + background-color: $red; + } + } +} diff --git a/src/components/lab/LabBanner/index.tsx b/src/components/lab/LabBanner/index.tsx new file mode 100644 index 00000000..df8f60ea --- /dev/null +++ b/src/components/lab/LabBanner/index.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; +import styles from './styles.module.scss'; +import { Card } from '~/components/containers/Card'; +import { Placeholder } from '~/components/placeholders/Placeholder'; +import { Group } from '~/components/containers/Group'; + +interface IProps {} + +const LabBanner: FC<IProps> = () => ( + <Card className={styles.wrap}> + <Group> + <Placeholder height={32} /> + <Placeholder height={18} width="120px" /> + <Placeholder height={18} width="200px" /> + <Placeholder height={18} width="60px" /> + <Placeholder height={18} width="180px" /> + <Placeholder height={18} width="230px" /> + </Group> + </Card> +); + +export { LabBanner }; diff --git a/src/components/lab/LabBanner/styles.module.scss b/src/components/lab/LabBanner/styles.module.scss new file mode 100644 index 00000000..f235ed41 --- /dev/null +++ b/src/components/lab/LabBanner/styles.module.scss @@ -0,0 +1,5 @@ +@import "~/styles/variables.scss"; + +.wrap { + background: $red_gradient_alt; +} diff --git a/src/components/lab/LabHead/index.tsx b/src/components/lab/LabHead/index.tsx new file mode 100644 index 00000000..25fb6cfd --- /dev/null +++ b/src/components/lab/LabHead/index.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { Group } from '~/components/containers/Group'; +import { Card } from '~/components/containers/Card'; +import { Placeholder } from '~/components/placeholders/Placeholder'; +import { Filler } from '~/components/containers/Filler'; + +interface IProps {} + +const LabHead: FC<IProps> = () => ( + <Card> + <Group horizontal> + <Group horizontal style={{ flex: '0 0 auto' }}> + <Placeholder width="32px" height={32} /> + <Placeholder width="96px" height={18} /> + </Group> + + <Group horizontal style={{ flex: '0 0 auto' }}> + <Placeholder width="32px" height={32} /> + <Placeholder width="126px" height={18} /> + </Group> + + <Group horizontal style={{ flex: '0 0 auto' }}> + <Placeholder width="32px" height={32} /> + <Placeholder width="96px" height={18} /> + </Group> + + <Filler /> + </Group> + </Card> +); + +export { LabHead }; diff --git a/src/components/lab/LabHero/index.tsx b/src/components/lab/LabHero/index.tsx new file mode 100644 index 00000000..1a059815 --- /dev/null +++ b/src/components/lab/LabHero/index.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; +import { Placeholder } from '~/components/placeholders/Placeholder'; +import { Group } from '~/components/containers/Group'; +import { Icon } from '~/components/input/Icon'; +import styles from './styles.module.scss'; + +interface IProps {} + +const LabHero: FC<IProps> = () => ( + <Group horizontal className={styles.wrap1}> + <div className={styles.star}> + <Icon icon="star_full" size={32} /> + </div> + + <Group> + <Placeholder height={20} /> + <Placeholder height={12} width="100px" /> + </Group> + </Group> +); + +export { LabHero }; diff --git a/src/components/lab/LabHero/styles.module.scss b/src/components/lab/LabHero/styles.module.scss new file mode 100644 index 00000000..b1a7e9cc --- /dev/null +++ b/src/components/lab/LabHero/styles.module.scss @@ -0,0 +1,10 @@ +@import "~/styles/variables.scss"; + +.wrap { + margin-bottom: $gap; +} + +.star { + fill: #2c2c2c; +} + diff --git a/src/components/lab/LabNode/index.tsx b/src/components/lab/LabNode/index.tsx new file mode 100644 index 00000000..5f64c55f --- /dev/null +++ b/src/components/lab/LabNode/index.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; +import { INode } from '~/redux/types'; +import { NodePanelInner } from '~/components/node/NodePanelInner'; +import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks'; +import styles from './styles.module.scss'; +import { Card } from '~/components/containers/Card'; +import { NodePanelLab } from '~/components/node/NodePanelLab'; + +interface IProps { + node: INode; +} + +const LabNode: FC<IProps> = ({ node }) => { + const { inline, block, head } = useNodeBlocks(node, false); + + console.log(node.id, { inline, block, head }); + + return ( + <Card seamless className={styles.wrap}> + <div className={styles.head}> + <NodePanelLab node={node} /> + </div> + + {head} + {block} + {inline} + </Card> + ); +}; + +export { LabNode }; diff --git a/src/components/lab/LabNode/styles.module.scss b/src/components/lab/LabNode/styles.module.scss new file mode 100644 index 00000000..0e8e59ab --- /dev/null +++ b/src/components/lab/LabNode/styles.module.scss @@ -0,0 +1,11 @@ +@import "~/styles/variables.scss"; + +.wrap { + min-width: 0; +} + +.head { + background-color: transparentize(black, 0.9); + border-radius: $radius $radius 0 0; +} + diff --git a/src/components/main/Header/index.tsx b/src/components/main/Header/index.tsx index 43d7646c..014e12a1 100644 --- a/src/components/main/Header/index.tsx +++ b/src/components/main/Header/index.tsx @@ -21,6 +21,7 @@ import * as MODAL_ACTIONS from '~/redux/modal/actions'; import * as AUTH_ACTIONS from '~/redux/auth/actions'; import { IState } from '~/redux/store'; import isBefore from 'date-fns/isBefore'; +import { Superpower } from '~/components/boris/Superpower'; const mapStateToProps = (state: IState) => ({ user: pick(['username', 'is_user', 'photo', 'last_seen_boris'])(selectUser(state)), @@ -89,6 +90,15 @@ const HeaderUnconnected: FC<IProps> = memo( ФЛОУ </Link> + <Superpower> + <Link + className={classNames(styles.item, { [styles.is_active]: pathname === URLS.BASE })} + to={URLS.LAB} + > + ЛАБ + </Link> + </Superpower> + <Link className={classNames(styles.item, { [styles.is_active]: pathname === URLS.BORIS, @@ -122,9 +132,6 @@ const HeaderUnconnected: FC<IProps> = memo( } ); -const Header = connect( - mapStateToProps, - mapDispatchToProps -)(HeaderUnconnected); +const Header = connect(mapStateToProps, mapDispatchToProps)(HeaderUnconnected); export { Header }; diff --git a/src/components/node/NodeAudioBlock/index.tsx b/src/components/node/NodeAudioBlock/index.tsx index 9581a16b..c064e831 100644 --- a/src/components/node/NodeAudioBlock/index.tsx +++ b/src/components/node/NodeAudioBlock/index.tsx @@ -4,14 +4,12 @@ import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { AudioPlayer } from '~/components/media/AudioPlayer'; import styles from './styles.module.scss'; import { INodeComponentProps } from '~/redux/node/constants'; +import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios'; interface IProps extends INodeComponentProps {} const NodeAudioBlock: FC<IProps> = ({ node }) => { - const audios = useMemo( - () => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), - [node.files] - ); + const audios = useNodeAudios(node); return ( <div className={styles.wrap}> diff --git a/src/components/node/NodeAudioImageBlock/index.tsx b/src/components/node/NodeAudioImageBlock/index.tsx index ca848b3f..e7834737 100644 --- a/src/components/node/NodeAudioImageBlock/index.tsx +++ b/src/components/node/NodeAudioImageBlock/index.tsx @@ -6,14 +6,12 @@ import { path } from 'ramda'; import { getURL } from '~/utils/dom'; import { PRESETS } from '~/constants/urls'; import { INodeComponentProps } from '~/redux/node/constants'; +import { useNodeImages } from '~/utils/hooks/node/useNodeImages'; interface IProps extends INodeComponentProps {} const NodeAudioImageBlock: FC<IProps> = ({ node }) => { - const images = useMemo( - () => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), - [node.files] - ); + const images = useNodeImages(node); if (images.length === 0) return null; diff --git a/src/components/node/NodeBottomBlock/index.tsx b/src/components/node/NodeBottomBlock/index.tsx index 73a114e6..d06da73c 100644 --- a/src/components/node/NodeBottomBlock/index.tsx +++ b/src/components/node/NodeBottomBlock/index.tsx @@ -2,16 +2,16 @@ import React, { FC } from 'react'; import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; import { Group } from '~/components/containers/Group'; import { Padder } from '~/components/containers/Padder'; -import styles from '~/containers/node/NodeLayout/styles.module.scss'; import { NodeCommentsBlock } from '~/components/node/NodeCommentsBlock'; import { NodeCommentForm } from '~/components/node/NodeCommentForm'; -import { Sticky } from '~/components/containers/Sticky'; import { NodeRelatedBlock } from '~/components/node/NodeRelatedBlock'; import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks'; import { IComment, INode } from '~/redux/types'; import { useUser } from '~/utils/hooks/user/userUser'; import { NodeTagsBlock } from '~/components/node/NodeTagsBlock'; import { INodeRelated } from '~/redux/node/types'; +import StickyBox from 'react-sticky-box/dist/esnext'; +import styles from './styles.module.scss'; interface IProps { node: INode; @@ -59,12 +59,12 @@ const NodeBottomBlock: FC<IProps> = ({ </Group> <div className={styles.panel}> - <Sticky> + <StickyBox className={styles.sticky} offsetTop={72}> <Group style={{ flex: 1, minWidth: 0 }}> <NodeTagsBlock node={node} isLoading={isLoading} /> <NodeRelatedBlock isLoading={isLoading} node={node} related={related} /> </Group> - </Sticky> + </StickyBox> </div> </Group> </Padder> diff --git a/src/components/node/NodeBottomBlock/styles.module.scss b/src/components/node/NodeBottomBlock/styles.module.scss new file mode 100644 index 00000000..a34d0d9b --- /dev/null +++ b/src/components/node/NodeBottomBlock/styles.module.scss @@ -0,0 +1,48 @@ +@import "~/styles/variables.scss"; + +.sticky { + width: 100%; +} + +.content { + align-items: stretch !important; + @include vertical_at_tablet; +} + +.comments { + flex: 3 1; + min-width: 0; + display: flex; + align-items: stretch; + justify-content: flex-start; + flex-direction: column; + + @media (max-width: 1024px) { + flex: 2 1; + } +} + + +.panel { + flex: 1 3; + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding-left: $gap / 2; + min-width: 0; + position: relative; + z-index: 10; + + @media (max-width: 1024px) { + padding-left: 0; + padding-top: $comment_height / 2; + flex: 1 2; + } +} + +.buttons { + background: $node_buttons_bg; + flex: 1; + border-radius: $panel_radius; + box-shadow: $comment_shadow; +} diff --git a/src/components/node/NodeImageSwiperBlock/index.tsx b/src/components/node/NodeImageSwiperBlock/index.tsx index c6011813..417ae430 100644 --- a/src/components/node/NodeImageSwiperBlock/index.tsx +++ b/src/components/node/NodeImageSwiperBlock/index.tsx @@ -1,12 +1,13 @@ import React, { FC, useCallback, useEffect, useState } from 'react'; import { INodeComponentProps } from '~/redux/node/constants'; -import SwiperCore, { A11y, Pagination, SwiperOptions } from 'swiper'; +import SwiperCore, { A11y, Pagination, Navigation, SwiperOptions, Keyboard } from 'swiper'; import { Swiper, SwiperSlide } from 'swiper/react'; import 'swiper/swiper.scss'; import 'swiper/components/pagination/pagination.scss'; import 'swiper/components/scrollbar/scrollbar.scss'; import 'swiper/components/zoom/zoom.scss'; +import 'swiper/components/navigation/navigation.scss'; import styles from './styles.module.scss'; import { useNodeImages } from '~/utils/hooks/node/useNodeImages'; @@ -16,13 +17,14 @@ import SwiperClass from 'swiper/types/swiper-class'; import { modalShowPhotoswipe } from '~/redux/modal/actions'; import { useDispatch } from 'react-redux'; -SwiperCore.use([Pagination, A11y]); +SwiperCore.use([Navigation, Pagination, A11y]); interface IProps extends INodeComponentProps {} const breakpoints: SwiperOptions['breakpoints'] = { 599: { spaceBetween: 20, + navigation: true, }, }; @@ -43,6 +45,7 @@ const NodeImageSwiperBlock: FC<IProps> = ({ node }) => { const resetSwiper = useCallback(() => { if (!controlledSwiper) return; controlledSwiper.slideTo(0, 0); + setTimeout(() => controlledSwiper.slideTo(0, 0), 300); }, [controlledSwiper]); useEffect(() => { @@ -74,7 +77,12 @@ const NodeImageSwiperBlock: FC<IProps> = ({ node }) => { observeParents resizeObserver watchOverflow + updateOnImagesReady onInit={resetSwiper} + keyboard={{ + enabled: true, + onlyInViewport: false, + }} zoom > {images.map(file => ( diff --git a/src/components/node/NodeImageSwiperBlock/styles.module.scss b/src/components/node/NodeImageSwiperBlock/styles.module.scss index 7766bfe5..6dd0a49b 100644 --- a/src/components/node/NodeImageSwiperBlock/styles.module.scss +++ b/src/components/node/NodeImageSwiperBlock/styles.module.scss @@ -20,6 +20,17 @@ :global(.swiper-container) { width: 100vw; } + + :global(.swiper-button-next), + :global(.swiper-button-prev) { + color: white; + font-size: 10px; + + &::after { + font-size: 32px; + } + } + } .slide { diff --git a/src/components/node/NodePanelInner/styles.module.scss b/src/components/node/NodePanelInner/styles.module.scss index 58d925fa..c9b9a94b 100644 --- a/src/components/node/NodePanelInner/styles.module.scss +++ b/src/components/node/NodePanelInner/styles.module.scss @@ -31,8 +31,6 @@ .wrap { display: flex; - align-items: center; - justify-content: stretch; position: relative; width: 100%; flex-direction: row; @@ -88,7 +86,7 @@ @include tablet { white-space: nowrap; padding-bottom: 0; - font: $font_20_semibold; + font: $font_16_semibold; } } diff --git a/src/components/node/NodePanelLab/index.tsx b/src/components/node/NodePanelLab/index.tsx new file mode 100644 index 00000000..c33714d4 --- /dev/null +++ b/src/components/node/NodePanelLab/index.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import { INode } from '~/redux/types'; +import styles from './styles.module.scss'; +import { URLS } from '~/constants/urls'; +import { Link } from 'react-router-dom'; + +interface IProps { + node: INode; +} + +const NodePanelLab: FC<IProps> = ({ node }) => ( + <div className={styles.wrap}> + <div className={styles.title}> + <Link to={URLS.NODE_URL(node.id)}>{node.title || '...'}</Link> + </div> + </div> +); + +export { NodePanelLab }; diff --git a/src/components/node/NodePanelLab/styles.module.scss b/src/components/node/NodePanelLab/styles.module.scss new file mode 100644 index 00000000..095dafe5 --- /dev/null +++ b/src/components/node/NodePanelLab/styles.module.scss @@ -0,0 +1,24 @@ +@import "~/styles/variables.scss"; + +.wrap { + padding: $gap; +} + +.title { + text-transform: uppercase; + font: $font_24_semibold; + overflow: hidden; + flex: 1; + text-overflow: ellipsis; + + a { + text-decoration: none; + color: inherit; + } + + @include tablet { + white-space: nowrap; + padding-bottom: 0; + font: $font_16_semibold; + } +} diff --git a/src/components/node/NodeRelatedItem/index.tsx b/src/components/node/NodeRelatedItem/index.tsx index 88cf1bcd..c3a6903c 100644 --- a/src/components/node/NodeRelatedItem/index.tsx +++ b/src/components/node/NodeRelatedItem/index.tsx @@ -12,7 +12,7 @@ type IProps = RouteComponentProps & { type CellSize = 'small' | 'medium' | 'large'; -const getTitleLetters = (title: string): string => { +const getTitleLetters = (title?: string): string => { const words = (title && title.split(' ')) || []; if (!words.length) return ''; diff --git a/src/constants/api.ts b/src/constants/api.ts index 6f57d689..c9c4c287 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -50,4 +50,7 @@ export const API = { NODES: `/tag/nodes`, AUTOCOMPLETE: `/tag/autocomplete`, }, + LAB: { + NODES: `/lab/`, + }, }; diff --git a/src/constants/urls.ts b/src/constants/urls.ts index 67eb4a39..527e072e 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -2,6 +2,7 @@ import { INode } from '~/redux/types'; export const URLS = { BASE: '/', + LAB: '/lab', BORIS: '/boris', AUTH: { LOGIN: '/auth/login', diff --git a/src/containers/dialogs/EditorDialog/index.tsx b/src/containers/dialogs/EditorDialog/index.tsx index a5a42243..564b5dfc 100644 --- a/src/containers/dialogs/EditorDialog/index.tsx +++ b/src/containers/dialogs/EditorDialog/index.tsx @@ -95,7 +95,7 @@ const EditorDialogUnconnected: FC<IProps> = ({ maxLength={256} /> - <Button title="Сохранить" iconRight="check" /> + <Button title="Сохранить" iconRight="check" color={data.is_promoted ? 'primary' : 'lab'} /> </Group> </Padder> ); diff --git a/src/containers/dialogs/LoginSocialRegisterDialog/index.tsx b/src/containers/dialogs/LoginSocialRegisterDialog/index.tsx index 64789120..ff210210 100644 --- a/src/containers/dialogs/LoginSocialRegisterDialog/index.tsx +++ b/src/containers/dialogs/LoginSocialRegisterDialog/index.tsx @@ -11,6 +11,7 @@ import { selectAuthRegisterSocial } from '~/redux/auth/selectors'; import * as AUTH_ACTIONS from '~/redux/auth/actions'; import { useCloseOnEscape } from '~/utils/hooks'; import { LoginSocialRegisterButtons } from '~/containers/dialogs/LoginSocialRegisterButtons'; +import { Toggle } from '~/components/input/Toggle'; const mapStateToProps = selectAuthRegisterSocial; const mapDispatchToProps = { @@ -21,6 +22,12 @@ const mapDispatchToProps = { type Props = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & IDialogProps & {}; +const phrase = [ + 'Сушёный кабачок особенно хорош в это время года, знаете ли.', + 'Бывало, стреляешь по кабачку, или он стреляет в тебя.', + 'Он всегда рядом, кабачок -- первый сорт! Надежда империи.', +]; + const LoginSocialRegisterDialogUnconnected: FC<Props> = ({ onRequestClose, errors, @@ -32,6 +39,7 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({ }) => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [isDryingPants, setIsDryingPants] = useState(false); const onSubmit = useCallback( (event: FormEvent) => { @@ -56,7 +64,7 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({ useCloseOnEscape(onRequestClose); return ( - <form onSubmit={onSubmit}> + <form onSubmit={onSubmit} autoComplete="new-password"> <BetterScrollDialog onClose={onRequestClose} width={300} @@ -73,6 +81,7 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({ value={username} title="Юзернэйм" error={errors.username} + autoComplete="new-password" /> <InputText @@ -81,12 +90,18 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({ title="Пароль" type="password" error={errors.password} + autoComplete="new-password" /> - <label className={styles.check}> - <input type="checkbox" /> + <div className={styles.check} onClick={() => setIsDryingPants(!isDryingPants)}> + <Toggle value={isDryingPants} color="primary" /> <span>Это не мои штаны сушатся на радиаторе в третьей лаборатории</span> - </label> + </div> + + <div className={styles.check} onClick={() => setIsDryingPants(!isDryingPants)}> + <Toggle value={!isDryingPants} color="primary" /> + <span>{phrase[Math.floor(Math.random() * phrase.length)]}</span> + </div> </Group> </div> </Padder> diff --git a/src/containers/lab/LabGrid/index.tsx b/src/containers/lab/LabGrid/index.tsx new file mode 100644 index 00000000..b6961b5f --- /dev/null +++ b/src/containers/lab/LabGrid/index.tsx @@ -0,0 +1,21 @@ +import React, { FC } from 'react'; +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import styles from './styles.module.scss'; +import { LabNode } from '~/components/lab/LabNode'; +import { selectLabListNodes } from '~/redux/lab/selectors'; + +interface IProps {} + +const LabGrid: FC<IProps> = () => { + const nodes = useShallowSelect(selectLabListNodes); + + return ( + <div className={styles.wrap}> + {nodes.map(node => ( + <LabNode node={node} key={node.id} /> + ))} + </div> + ); +}; + +export { LabGrid }; diff --git a/src/containers/lab/LabGrid/styles.module.scss b/src/containers/lab/LabGrid/styles.module.scss new file mode 100644 index 00000000..3f42c360 --- /dev/null +++ b/src/containers/lab/LabGrid/styles.module.scss @@ -0,0 +1,8 @@ +@import "~/styles/variables.scss"; + +.wrap { + display: grid; + grid-auto-flow: row; + grid-auto-rows: auto; + grid-row-gap: $gap; +} diff --git a/src/containers/lab/LabLayout/index.tsx b/src/containers/lab/LabLayout/index.tsx new file mode 100644 index 00000000..df778d28 --- /dev/null +++ b/src/containers/lab/LabLayout/index.tsx @@ -0,0 +1,112 @@ +import React, { FC, useEffect } from 'react'; +import styles from './styles.module.scss'; +import { Card } from '~/components/containers/Card'; +import { Sticky } from '~/components/containers/Sticky'; +import { Container } from '~/containers/main/Container'; +import { LabGrid } from '~/containers/lab/LabGrid'; +import { useDispatch } from 'react-redux'; +import { labGetList } from '~/redux/lab/actions'; +import { Placeholder } from '~/components/placeholders/Placeholder'; +import { Grid } from '~/components/containers/Grid'; +import { Group } from '~/components/containers/Group'; +import { LabHero } from '~/components/lab/LabHero'; +import { LabBanner } from '~/components/lab/LabBanner'; +import { LabHead } from '~/components/lab/LabHead'; +import { Filler } from '~/components/containers/Filler'; + +interface IProps {} + +const LabLayout: FC<IProps> = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(labGetList()); + }, [dispatch]); + + return ( + <div> + <Container> + <div className={styles.wrap}> + <Group className={styles.content}> + <LabHead /> + <LabGrid /> + </Group> + + <div className={styles.panel}> + <Sticky> + <Group> + <LabBanner /> + + <Card> + <Group> + <Placeholder height={36} width="100%" /> + <Group horizontal> + <Filler /> + <Placeholder height={32} width="120px" /> + </Group> + + <div /> + <div /> + + <Placeholder height={14} width="100px" /> + + <div /> + + <div className={styles.tags}> + <Placeholder height={20} width="100px" /> + <Placeholder height={20} width="64px" /> + <Placeholder height={20} width="100%" /> + <Placeholder height={20} width="100px" /> + <Placeholder height={20} width="100px" /> + <Placeholder height={20} width="64px" /> + </div> + + <div /> + <div /> + + <Placeholder height={14} width="180px" /> + + <div /> + + <Group className={styles.heroes}> + <LabHero /> + <div /> + <LabHero /> + <div /> + <LabHero /> + <div /> + <LabHero /> + <div /> + <LabHero /> + <div /> + <LabHero /> + <div /> + <LabHero /> + </Group> + + <div /> + <div /> + + <Group> + <Placeholder width="100%" height={100} /> + <Placeholder width="120px" height={16} /> + </Group> + + <div /> + + <Group> + <Placeholder width="100%" height={100} /> + <Placeholder width="120px" height={16} /> + </Group> + </Group> + </Card> + </Group> + </Sticky> + </div> + </div> + </Container> + </div> + ); +}; + +export { LabLayout }; diff --git a/src/containers/lab/LabLayout/styles.module.scss b/src/containers/lab/LabLayout/styles.module.scss new file mode 100644 index 00000000..ee69442c --- /dev/null +++ b/src/containers/lab/LabLayout/styles.module.scss @@ -0,0 +1,20 @@ +@import "~/styles/variables.scss"; + +.wrap { + display: grid; + grid-template-columns: 3fr 1fr; + column-gap: $gap; +} + +.panel { + margin-top: -7px; +} + +.tags { + display: flex; + flex-wrap: wrap; + + & > * { + margin: 0 $gap $gap 0; + } +} diff --git a/src/containers/main/Container/styles.module.scss b/src/containers/main/Container/styles.module.scss index 7fe7bafa..f6decbd0 100644 --- a/src/containers/main/Container/styles.module.scss +++ b/src/containers/main/Container/styles.module.scss @@ -8,4 +8,8 @@ @include tablet { padding: 0; } + + @media (max-width: $content_width + $gap * 4) { + padding: 0; + } } diff --git a/src/containers/main/MainRouter/index.tsx b/src/containers/main/MainRouter/index.tsx index bed1579a..ac8988dc 100644 --- a/src/containers/main/MainRouter/index.tsx +++ b/src/containers/main/MainRouter/index.tsx @@ -6,10 +6,14 @@ import { BorisLayout } from '~/containers/node/BorisLayout'; import { ErrorNotFound } from '~/containers/pages/ErrorNotFound'; import { ProfilePage } from '~/containers/profile/ProfilePage'; import { Redirect, Route, Switch, useLocation } from 'react-router'; +import { LabLayout } from '~/containers/lab/LabLayout'; +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { selectAuthUser } from '~/redux/auth/selectors'; interface IProps {} const MainRouter: FC<IProps> = () => { + const { is_user } = useShallowSelect(selectAuthUser); const location = useLocation(); return ( @@ -20,6 +24,12 @@ const MainRouter: FC<IProps> = () => { <Route path={URLS.ERRORS.NOT_FOUND} component={ErrorNotFound} /> <Route path={URLS.PROFILE_PAGE(':username')} component={ProfilePage} /> + {is_user && ( + <> + <Route exact path={URLS.LAB} component={LabLayout} /> + </> + )} + <Redirect to="/" /> </Switch> ); diff --git a/src/containers/node/BorisLayout/index.tsx b/src/containers/node/BorisLayout/index.tsx index 57127226..42a93e70 100644 --- a/src/containers/node/BorisLayout/index.tsx +++ b/src/containers/node/BorisLayout/index.tsx @@ -1,25 +1,30 @@ -import React, { FC, useEffect } from 'react'; +import React, { FC, useCallback, useEffect } from 'react'; import { selectNode, selectNodeComments } from '~/redux/node/selectors'; -import { selectUser } from '~/redux/auth/selectors'; +import { selectAuthIsTester, selectUser } from '~/redux/auth/selectors'; import { useDispatch } from 'react-redux'; -import { NodeComments } from '~/components/node/NodeComments'; import styles from './styles.module.scss'; import { Group } from '~/components/containers/Group'; import boris from '~/sprites/boris_robot.svg'; -import { NodeNoComments } from '~/components/node/NodeNoComments'; import { useRandomPhrase } from '~/constants/phrases'; -import { NodeCommentForm } from '~/components/node/NodeCommentForm'; import isBefore from 'date-fns/isBefore'; -import { Card } from '~/components/containers/Card'; -import { Footer } from '~/components/main/Footer'; -import { Sticky } from '~/components/containers/Sticky'; import { BorisStats } from '~/components/boris/BorisStats'; import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; import { selectBorisStats } from '~/redux/boris/selectors'; -import { authSetUser } from '~/redux/auth/actions'; +import { authSetState, authSetUser } from '~/redux/auth/actions'; import { nodeLoadNode } from '~/redux/node/actions'; import { borisLoadStats } from '~/redux/boris/actions'; import { Container } from '~/containers/main/Container'; +import StickyBox from 'react-sticky-box/dist/esnext'; +import { BorisComments } from '~/components/boris/BorisComments'; +import { URLS } from '~/constants/urls'; +import { Route, Switch, Link } from 'react-router-dom'; +import { BorisUIDemo } from '~/components/boris/BorisUIDemo'; +import { BorisSuperpowers } from '~/components/boris/BorisSuperpowers'; +import { Superpower } from '~/components/boris/Superpower'; +import { Tabs } from '~/components/dialogs/Tabs'; +import { Tab } from '~/components/dialogs/Tab'; +import { useHistory, useLocation } from 'react-router'; +import { Card } from '~/components/containers/Card'; type IProps = {}; @@ -30,6 +35,7 @@ const BorisLayout: FC<IProps> = () => { const user = useShallowSelect(selectUser); const stats = useShallowSelect(selectBorisStats); const comments = useShallowSelect(selectNodeComments); + const is_tester = useShallowSelect(selectAuthIsTester); useEffect(() => { const last_comment = comments[0]; @@ -55,6 +61,16 @@ const BorisLayout: FC<IProps> = () => { dispatch(borisLoadStats()); }, [dispatch]); + const setBetaTester = useCallback( + (is_tester: boolean) => { + dispatch(authSetState({ is_tester })); + }, + [dispatch] + ); + + const history = useHistory(); + const location = useLocation(); + return ( <Container> <div className={styles.wrap}> @@ -70,26 +86,40 @@ const BorisLayout: FC<IProps> = () => { <div className={styles.container}> <Card className={styles.content}> - <Group className={styles.grid}> - {user.is_user && <NodeCommentForm isBefore nodeId={node.current.id} />} + <Superpower> + <Tabs> + <Tab + active={location.pathname === URLS.BORIS} + onClick={() => history.push(URLS.BORIS)} + > + Комментарии + </Tab> - {node.is_loading_comments ? ( - <NodeNoComments is_loading count={7} /> - ) : ( - <NodeComments - comments={comments} - count={node.comment_count} - user={user} - order="ASC" + <Tab + active={location.pathname === `${URLS.BORIS}/ui`} + onClick={() => history.push(`${URLS.BORIS}/ui`)} + > + UI Demo + </Tab> + </Tabs> + </Superpower> + + { + <Switch> + <Route path={`${URLS.BORIS}/ui`} component={BorisUIDemo} /> + + <BorisComments + isLoadingComments={node.is_loading_comments} + commentCount={node.comment_count} + node={node.current} + comments={node.comments} /> - )} - </Group> - - <Footer /> + </Switch> + } </Card> <Group className={styles.stats}> - <Sticky> + <StickyBox className={styles.sticky} offsetTop={72} offsetBottom={10}> <Group className={styles.stats__container}> <div className={styles.stats__about}> <h4>Господи-боженьки, где это я?</h4> @@ -102,11 +132,15 @@ const BorisLayout: FC<IProps> = () => { <p className="grey">// Такова жизнь.</p> </div> + <div> + {user.is_user && <BorisSuperpowers active={is_tester} onChange={setBetaTester} />} + </div> + <div className={styles.stats__wrap}> <BorisStats stats={stats} /> </div> </Group> - </Sticky> + </StickyBox> </Group> </div> </div> diff --git a/src/containers/node/BorisLayout/styles.module.scss b/src/containers/node/BorisLayout/styles.module.scss index f6e3c8ad..c29e2f60 100644 --- a/src/containers/node/BorisLayout/styles.module.scss +++ b/src/containers/node/BorisLayout/styles.module.scss @@ -7,22 +7,6 @@ flex-direction: column; } -.content { - flex: 4; - z-index: 2; - border-radius: $radius; - padding: 0; - background: $node_bg; - box-shadow: inset transparentize(mix($wisegreen, white, 60%), 0.6) 0 1px; - - @include desktop { - flex: 2.5; - } - - @media(max-width: 1024px) { - flex: 2; - } -} .grid { padding: $gap; @@ -36,7 +20,7 @@ width: 100%; height: 100vh; overflow: hidden; - background: 50% 0% no-repeat url('../../../sprites/boris_bg.svg'); + background: 50% 0 no-repeat url('../../../sprites/boris_bg.svg'); background-size: cover; } @@ -167,3 +151,9 @@ } } } + +.content { + position: relative; + z-index: 1; + flex: 3; +} diff --git a/src/containers/node/NodeLayout/index.tsx b/src/containers/node/NodeLayout/index.tsx index 589b9072..35dd2cf2 100644 --- a/src/containers/node/NodeLayout/index.tsx +++ b/src/containers/node/NodeLayout/index.tsx @@ -40,7 +40,7 @@ const NodeLayout: FC<IProps> = memo( const { head, block } = useNodeBlocks(current, is_loading); return ( - <> + <div className={styles.wrap}> {head} <Container> @@ -64,7 +64,7 @@ const NodeLayout: FC<IProps> = memo( </Container> <SidebarRouter prefix="/post:id" /> - </> + </div> ); } ); diff --git a/src/containers/node/NodeLayout/styles.module.scss b/src/containers/node/NodeLayout/styles.module.scss index f799e1a4..0a824c0d 100644 --- a/src/containers/node/NodeLayout/styles.module.scss +++ b/src/containers/node/NodeLayout/styles.module.scss @@ -2,6 +2,7 @@ .content { align-items: stretch !important; + @include vertical_at_tablet; } diff --git a/src/containers/profile/ProfileLayout/index.tsx b/src/containers/profile/ProfileLayout/index.tsx index 24d623dd..bf1f9072 100644 --- a/src/containers/profile/ProfileLayout/index.tsx +++ b/src/containers/profile/ProfileLayout/index.tsx @@ -29,7 +29,9 @@ const ProfileLayoutUnconnected: FC<IProps> = ({ history, nodeSetCoverImage }) => useEffect(() => { if (user && user.id && user.cover) { nodeSetCoverImage(user.cover); - return () => nodeSetCoverImage(null); + return () => { + nodeSetCoverImage(undefined); + }; } }, [user]); diff --git a/src/containers/profile/ProfileTabs/index.tsx b/src/containers/profile/ProfileTabs/index.tsx index d576d4e2..1f893442 100644 --- a/src/containers/profile/ProfileTabs/index.tsx +++ b/src/containers/profile/ProfileTabs/index.tsx @@ -2,6 +2,8 @@ import React, { FC, useCallback } from 'react'; import styles from './styles.module.scss'; import classNames from 'classnames'; import { IAuthState } from '~/redux/auth/types'; +import { Tabs } from '~/components/dialogs/Tabs'; +import { Tab } from '~/components/dialogs/Tab'; interface IProps { tab: string; @@ -20,28 +22,20 @@ const ProfileTabs: FC<IProps> = ({ tab, is_own, setTab }) => { return ( <div className={styles.wrap}> - <div - className={classNames(styles.tab, { [styles.active]: tab === 'profile' })} - onClick={changeTab('profile')} - > - Профиль - </div> - <div - className={classNames(styles.tab, { [styles.active]: tab === 'messages' })} - onClick={changeTab('messages')} - > - Сообщения - </div> - {is_own && ( - <> - <div - className={classNames(styles.tab, { [styles.active]: tab === 'settings' })} - onClick={changeTab('settings')} - > + <Tabs> + <Tab active={tab === 'profile'} onClick={changeTab('profile')}> + Профиль + </Tab> + + <Tab active={tab === 'messages'} onClick={changeTab('messages')}> + Сообщения + </Tab> + {is_own && ( + <Tab active={tab === 'settings'} onClick={changeTab('settings')}> Настройки - </div> - </> - )} + </Tab> + )} + </Tabs> </div> ); }; diff --git a/src/containers/profile/ProfileTabs/styles.module.scss b/src/containers/profile/ProfileTabs/styles.module.scss index 29f68b7b..929f2a53 100644 --- a/src/containers/profile/ProfileTabs/styles.module.scss +++ b/src/containers/profile/ProfileTabs/styles.module.scss @@ -1,24 +1,6 @@ @import "src/styles/variables"; .wrap { - display: flex; - align-items: flex-start; - justify-content: flex-start; margin: $gap * 2 0 0 0; padding: 0 $gap; } - -.tab { - @include outer_shadow(); - - padding: $gap; - margin-right: $gap; - border-radius: $radius $radius 0 0; - font: $font_14_semibold; - text-transform: uppercase; - cursor: pointer; - - &.active { - background: lighten($content_bg, 4%); - } -} diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 33614334..f0b333d4 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -3,5 +3,6 @@ declare namespace NodeJS { interface ProcessEnv { readonly REACT_APP_API_URL: string; readonly REACT_APP_REMOTE_CURRENT: string; + readonly REACT_APP_LAB_ENABLED: string; } } diff --git a/src/redux/auth/actions.ts b/src/redux/auth/actions.ts index 9c2dd8c9..311414c2 100644 --- a/src/redux/auth/actions.ts +++ b/src/redux/auth/actions.ts @@ -20,6 +20,11 @@ export const authSetToken = (token: IAuthState['token']) => ({ token, }); +export const authSetState = (payload: Partial<IAuthState>) => ({ + type: AUTH_USER_ACTIONS.SET_STATE, + payload, +}); + export const gotAuthPostMessage = ({ token }: { token: string }) => ({ type: AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, token, diff --git a/src/redux/auth/api.ts b/src/redux/auth/api.ts index 02869974..0224759f 100644 --- a/src/redux/auth/api.ts +++ b/src/redux/auth/api.ts @@ -1,6 +1,5 @@ -import { api, cleanResult, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; -import { IResultWithStatus } from '~/redux/types'; import { ApiAttachSocialRequest, ApiAttachSocialResult, diff --git a/src/redux/auth/constants.ts b/src/redux/auth/constants.ts index d2959fdc..21e6f4ca 100644 --- a/src/redux/auth/constants.ts +++ b/src/redux/auth/constants.ts @@ -3,6 +3,7 @@ import { IToken, IUser } from '~/redux/auth/types'; export const AUTH_USER_ACTIONS = { SEND_LOGIN_REQUEST: 'SEND_LOGIN_REQUEST', SET_LOGIN_ERROR: 'SET_LOGIN_ERROR', + SET_STATE: 'SET_STATE', SET_USER: 'SET_USER', SET_TOKEN: 'SET_TOKEN', diff --git a/src/redux/auth/handlers.ts b/src/redux/auth/handlers.ts index 210c919b..736ddf86 100644 --- a/src/redux/auth/handlers.ts +++ b/src/redux/auth/handlers.ts @@ -25,6 +25,11 @@ const setUser: ActionHandler<typeof ActionCreators.authSetUser> = (state, { prof }, }); +const setState: ActionHandler<typeof ActionCreators.authSetState> = (state, { payload }) => ({ + ...state, + ...payload, +}); + const setToken: ActionHandler<typeof ActionCreators.authSetToken> = (state, { token }) => ({ ...state, token, @@ -104,6 +109,7 @@ const setRegisterSocialErrors: ActionHandler<typeof ActionCreators.authSetRegist export const AUTH_USER_HANDLERS = { [AUTH_USER_ACTIONS.SET_LOGIN_ERROR]: setLoginError, [AUTH_USER_ACTIONS.SET_USER]: setUser, + [AUTH_USER_ACTIONS.SET_STATE]: setState, [AUTH_USER_ACTIONS.SET_TOKEN]: setToken, [AUTH_USER_ACTIONS.SET_PROFILE]: setProfile, [AUTH_USER_ACTIONS.SET_UPDATES]: setUpdates, diff --git a/src/redux/auth/index.ts b/src/redux/auth/index.ts index 357cb37e..941f13a3 100644 --- a/src/redux/auth/index.ts +++ b/src/redux/auth/index.ts @@ -10,6 +10,7 @@ const HANDLERS = { const INITIAL_STATE: IAuthState = { token: '', user: { ...EMPTY_USER }, + is_tester: false, updates: { last: '', diff --git a/src/redux/auth/selectors.ts b/src/redux/auth/selectors.ts index 6f6ed43b..0b789bef 100644 --- a/src/redux/auth/selectors.ts +++ b/src/redux/auth/selectors.ts @@ -2,6 +2,7 @@ import { IState } from '~/redux/store'; export const selectAuth = (state: IState) => state.auth; export const selectUser = (state: IState) => state.auth.user; +export const selectAuthIsTester = (state: IState) => state.auth.is_tester; export const selectToken = (state: IState) => state.auth.token; export const selectAuthLogin = (state: IState) => state.auth.login; export const selectAuthProfile = (state: IState) => state.auth.profile; diff --git a/src/redux/auth/types.ts b/src/redux/auth/types.ts index 55d7ae82..eb7aa395 100644 --- a/src/redux/auth/types.ts +++ b/src/redux/auth/types.ts @@ -37,6 +37,8 @@ export type IAuthState = Readonly<{ user: IUser; token: string; + is_tester: boolean; + updates: { last: string; notifications: INotification[]; diff --git a/src/redux/boris/api.ts b/src/redux/boris/api.ts index c1bd5a72..d8cc8867 100644 --- a/src/redux/boris/api.ts +++ b/src/redux/boris/api.ts @@ -1,10 +1,20 @@ import git from '~/stats/git.json'; import { API } from '~/constants/api'; -import { api, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { IBorisState, IStatBackend } from './reducer'; -import { IResultWithStatus } from '../types'; +import axios from 'axios'; +import { IGetGithubIssuesResult } from '~/redux/boris/types'; export const getBorisGitStats = () => Promise.resolve<IBorisState['stats']['git']>(git); export const getBorisBackendStats = () => api.get<IStatBackend>(API.BORIS.GET_BACKEND_STATS).then(cleanResult); + +export const getGithubIssues = () => { + return axios + .get<IGetGithubIssuesResult>('https://api.github.com/repos/muerwre/vault-frontend/issues', { + params: { state: 'all', sort: 'created' }, + }) + .then(result => result.data) + .catch(() => []); +}; diff --git a/src/redux/boris/reducer.ts b/src/redux/boris/reducer.ts index 2032c793..5e182674 100644 --- a/src/redux/boris/reducer.ts +++ b/src/redux/boris/reducer.ts @@ -1,5 +1,6 @@ import { createReducer } from '~/utils/reducer'; import { BORIS_HANDLERS } from './handlers'; +import { IGithubIssue } from '~/redux/boris/types'; export type IStatGitRow = { commit: string; @@ -31,6 +32,7 @@ export type IStatBackend = { export type IBorisState = Readonly<{ stats: { git: Partial<IStatGitRow>[]; + issues: IGithubIssue[]; backend?: IStatBackend; is_loading: boolean; }; @@ -39,6 +41,7 @@ export type IBorisState = Readonly<{ const BORIS_INITIAL_STATE: IBorisState = { stats: { git: [], + issues: [], backend: undefined, is_loading: false, }, diff --git a/src/redux/boris/sagas.ts b/src/redux/boris/sagas.ts index a0b1d003..b17e2c16 100644 --- a/src/redux/boris/sagas.ts +++ b/src/redux/boris/sagas.ts @@ -1,17 +1,17 @@ -import { takeLatest, put, call } from 'redux-saga/effects'; +import { call, put, takeLatest } from 'redux-saga/effects'; import { BORIS_ACTIONS } from './constants'; import { borisSetStats } from './actions'; -import { getBorisGitStats, getBorisBackendStats } from './api'; +import { getBorisBackendStats, getGithubIssues } from './api'; import { Unwrap } from '../types'; function* loadStats() { try { yield put(borisSetStats({ is_loading: true })); - const git: Unwrap<typeof getBorisGitStats> = yield call(getBorisGitStats); const backend: Unwrap<typeof getBorisBackendStats> = yield call(getBorisBackendStats); + const issues: Unwrap<typeof getGithubIssues> = yield call(getGithubIssues); - yield put(borisSetStats({ git, backend })); + yield put(borisSetStats({ issues, backend })); } catch (e) { yield put(borisSetStats({ git: [], backend: undefined })); } finally { diff --git a/src/redux/boris/types.ts b/src/redux/boris/types.ts new file mode 100644 index 00000000..73552b25 --- /dev/null +++ b/src/redux/boris/types.ts @@ -0,0 +1,12 @@ +export interface IGithubIssue { + id: string; + url: string; + html_url: string; + body: string; + title: string; + state: 'open' | 'closed'; + created_at: string; + pull_request?: unknown; +} + +export type IGetGithubIssuesResult = IGithubIssue[]; diff --git a/src/redux/lab/actions.ts b/src/redux/lab/actions.ts new file mode 100644 index 00000000..1e1ff97a --- /dev/null +++ b/src/redux/lab/actions.ts @@ -0,0 +1,12 @@ +import { LAB_ACTIONS } from '~/redux/lab/constants'; +import { ILabState } from '~/redux/lab/types'; + +export const labGetList = (after?: string) => ({ + type: LAB_ACTIONS.GET_LIST, + after, +}); + +export const labSetList = (list: Partial<ILabState['list']>) => ({ + type: LAB_ACTIONS.SET_LIST, + list, +}); diff --git a/src/redux/lab/api.ts b/src/redux/lab/api.ts new file mode 100644 index 00000000..5fa97bc0 --- /dev/null +++ b/src/redux/lab/api.ts @@ -0,0 +1,8 @@ +import { api, cleanResult } from '~/utils/api'; +import { API } from '~/constants/api'; +import { GetLabNodesRequest, GetLabNodesResult } from '~/redux/lab/types'; + +export const getLabNodes = ({ after }: GetLabNodesRequest) => + api + .get<GetLabNodesResult>(API.LAB.NODES, { params: { after } }) + .then(cleanResult); diff --git a/src/redux/lab/constants.ts b/src/redux/lab/constants.ts new file mode 100644 index 00000000..d2e670da --- /dev/null +++ b/src/redux/lab/constants.ts @@ -0,0 +1,6 @@ +const prefix = 'LAB.'; + +export const LAB_ACTIONS = { + GET_LIST: `${prefix}GET_LIST`, + SET_LIST: `${prefix}SET_LIST`, +}; diff --git a/src/redux/lab/handlers.ts b/src/redux/lab/handlers.ts new file mode 100644 index 00000000..b09812e2 --- /dev/null +++ b/src/redux/lab/handlers.ts @@ -0,0 +1,20 @@ +import { LAB_ACTIONS } from '~/redux/lab/constants'; +import { labSetList } from '~/redux/lab/actions'; +import { ILabState } from '~/redux/lab/types'; + +type LabHandler<T extends (...args: any) => any> = ( + state: Readonly<ILabState>, + payload: ReturnType<T> +) => Readonly<ILabState>; + +const setList: LabHandler<typeof labSetList> = (state, { list }) => ({ + ...state, + list: { + ...state.list, + ...list, + }, +}); + +export const LAB_HANDLERS = { + [LAB_ACTIONS.SET_LIST]: setList, +}; diff --git a/src/redux/lab/index.ts b/src/redux/lab/index.ts new file mode 100644 index 00000000..56879a52 --- /dev/null +++ b/src/redux/lab/index.ts @@ -0,0 +1,14 @@ +import { createReducer } from '~/utils/reducer'; +import { LAB_HANDLERS } from '~/redux/lab/handlers'; +import { ILabState } from '~/redux/lab/types'; + +const INITIAL_STATE: ILabState = { + list: { + is_loading: false, + nodes: [], + count: 0, + error: '', + }, +}; + +export default createReducer(INITIAL_STATE, LAB_HANDLERS); diff --git a/src/redux/lab/sagas.ts b/src/redux/lab/sagas.ts new file mode 100644 index 00000000..5fc48b8b --- /dev/null +++ b/src/redux/lab/sagas.ts @@ -0,0 +1,21 @@ +import { takeLeading, call, put } from 'redux-saga/effects'; +import { labGetList, labSetList } from '~/redux/lab/actions'; +import { LAB_ACTIONS } from '~/redux/lab/constants'; +import { Unwrap } from '~/redux/types'; +import { getLabNodes } from '~/redux/lab/api'; + +function* getList({ after = '' }: ReturnType<typeof labGetList>) { + try { + yield put(labSetList({ is_loading: true })); + const { nodes, count }: Unwrap<typeof getLabNodes> = yield call(getLabNodes, { after }); + yield put(labSetList({ nodes, count })); + } catch (error) { + yield put(labSetList({ error: error.message })); + } finally { + yield put(labSetList({ is_loading: false })); + } +} + +export default function* labSaga() { + yield takeLeading(LAB_ACTIONS.GET_LIST, getList); +} diff --git a/src/redux/lab/selectors.ts b/src/redux/lab/selectors.ts new file mode 100644 index 00000000..0854ac25 --- /dev/null +++ b/src/redux/lab/selectors.ts @@ -0,0 +1,4 @@ +import { IState } from '~/redux/store'; + +export const selectLab = (state: IState) => state.lab; +export const selectLabListNodes = (state: IState) => state.lab.list.nodes; diff --git a/src/redux/lab/types.ts b/src/redux/lab/types.ts new file mode 100644 index 00000000..7614807b --- /dev/null +++ b/src/redux/lab/types.ts @@ -0,0 +1,19 @@ +import { IError, INode } from '~/redux/types'; + +export type ILabState = Readonly<{ + list: { + is_loading: boolean; + nodes: INode[]; + count: number; + error: IError; + }; +}>; + +export type GetLabNodesRequest = { + after?: string; +}; + +export type GetLabNodesResult = { + nodes: INode[]; + count: number; +}; diff --git a/src/redux/modal/sagas.ts b/src/redux/modal/sagas.ts index b6b5ffb4..1f04582f 100644 --- a/src/redux/modal/sagas.ts +++ b/src/redux/modal/sagas.ts @@ -10,13 +10,23 @@ function* onPathChange({ }, }: LocationChangeAction) { if (pathname.match(/^\/~([\wа-яА-Я]+)/)) { - const [, username] = pathname.match(/^\/~([\wа-яА-Я]+)/); - return yield put(authOpenProfile(username)); + const match = pathname.match(/^\/~([\wа-яА-Я]+)/); + + if (!match || !match.length || !match[1]) { + return; + } + + return yield put(authOpenProfile(match[1])); } if (pathname.match(/^\/restore\/([\w\-]+)/)) { - const [, code] = pathname.match(/^\/restore\/([\w\-]+)/); - return yield put(authShowRestoreModal(code)); + const match = pathname.match(/^\/restore\/([\w\-]+)/); + + if (!match || !match.length || !match[1]) { + return; + } + + return yield put(authShowRestoreModal(match[1])); } } diff --git a/src/redux/node/constants.ts b/src/redux/node/constants.ts index b7664ba8..8cc79869 100644 --- a/src/redux/node/constants.ts +++ b/src/redux/node/constants.ts @@ -13,6 +13,7 @@ import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadB import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton'; import { IEditorComponentProps, NodeEditorProps } from '~/redux/node/types'; import { EditorFiller } from '~/components/editors/EditorFiller'; +import { EditorPublicSwitch } from '~/components/editors/EditorPublicSwitch'; import { NodeImageSwiperBlock } from '~/components/node/NodeImageSwiperBlock'; const prefix = 'NODE.'; @@ -59,6 +60,8 @@ export const EMPTY_NODE: INode = { blocks: [], tags: [], + is_public: true, + is_promoted: true, flow: { display: 'single', @@ -112,14 +115,20 @@ export const NODE_EDITORS: Record< }; export const NODE_PANEL_COMPONENTS: Record<string, FC<IEditorComponentProps>[]> = { - [NODE_TYPES.TEXT]: [EditorFiller, EditorUploadCoverButton], - [NODE_TYPES.VIDEO]: [EditorFiller, EditorUploadCoverButton], - [NODE_TYPES.IMAGE]: [EditorImageUploadButton, EditorFiller, EditorUploadCoverButton], + [NODE_TYPES.TEXT]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch], + [NODE_TYPES.VIDEO]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch], + [NODE_TYPES.IMAGE]: [ + EditorImageUploadButton, + EditorFiller, + EditorUploadCoverButton, + EditorPublicSwitch, + ], [NODE_TYPES.AUDIO]: [ EditorAudioUploadButton, EditorImageUploadButton, EditorFiller, EditorUploadCoverButton, + EditorPublicSwitch, ], }; diff --git a/src/redux/store.ts b/src/redux/store.ts index eb9c60ff..6b19ebe9 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -17,6 +17,10 @@ import nodeSaga from '~/redux/node/sagas'; import flow, { IFlowState } from '~/redux/flow/reducer'; import flowSaga from '~/redux/flow/sagas'; +import lab from '~/redux/lab'; +import labSaga from '~/redux/lab/sagas'; +import { ILabState } from '~/redux/lab/types'; + import uploads, { IUploadState } from '~/redux/uploads/reducer'; import uploadSaga from '~/redux/uploads/sagas'; @@ -42,7 +46,7 @@ import { assocPath } from 'ramda'; const authPersistConfig: PersistConfig = { key: 'auth', - whitelist: ['token', 'user', 'updates'], + whitelist: ['token', 'user', 'updates', 'is_tester'], storage, }; @@ -69,13 +73,16 @@ export interface IState { boris: IBorisState; messages: IMessagesState; tag: ITagState; + lab: ILabState; } export const sagaMiddleware = createSagaMiddleware(); export const history = createBrowserHistory(); const composeEnhancers = - typeof window === 'object' && (<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ + typeof window === 'object' && + (<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && + process.env.NODE_ENV === 'development' ? (<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose; @@ -91,6 +98,7 @@ export const store = createStore( player: persistReducer(playerPersistConfig, player), messages, tag: tag, + lab: lab, }), composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware)) ); @@ -108,6 +116,7 @@ export function configureStore(): { sagaMiddleware.run(borisSaga); sagaMiddleware.run(messagesSaga); sagaMiddleware.run(tagSaga); + sagaMiddleware.run(labSaga); window.addEventListener('message', message => { if (message && message.data && message.data.type === 'oauth_login' && message.data.token) diff --git a/src/redux/tag/sagas.ts b/src/redux/tag/sagas.ts index eb1c3f16..90c5cf1f 100644 --- a/src/redux/tag/sagas.ts +++ b/src/redux/tag/sagas.ts @@ -11,7 +11,7 @@ import { apiGetTagSuggestions, apiGetNodesOfTag } from '~/redux/tag/api'; import { Unwrap } from '~/redux/types'; function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) { - yield put(tagSetNodes({ isLoading: true, list: [] })); + yield put(tagSetNodes({ isLoading: true })); try { const { list }: ReturnType<typeof selectTagNodes> = yield select(selectTagNodes); diff --git a/src/redux/types.ts b/src/redux/types.ts index 8dd92998..f78309a3 100644 --- a/src/redux/types.ts +++ b/src/redux/types.ts @@ -124,6 +124,8 @@ export interface INode { description?: string; is_liked?: boolean; is_heroic?: boolean; + is_promoted?: boolean; + is_public?: boolean; like_count?: number; flow: { diff --git a/src/sprites/Sprites.tsx b/src/sprites/Sprites.tsx index dbd5b6e2..f822d726 100644 --- a/src/sprites/Sprites.tsx +++ b/src/sprites/Sprites.tsx @@ -255,6 +255,16 @@ const Sprites: FC<{}> = () => ( <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" /> </g> + <g id="waves" stroke="none"> + <path fill="none" d="M0 0h24v24H0V0z" /> + <path d="M17 16.99c-1.35 0-2.2.42-2.95.8-.65.33-1.18.6-2.05.6-.9 0-1.4-.25-2.05-.6-.75-.38-1.57-.8-2.95-.8s-2.2.42-2.95.8c-.65.33-1.17.6-2.05.6v1.95c1.35 0 2.2-.42 2.95-.8.65-.33 1.17-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.42 2.95-.8c.65-.33 1.18-.6 2.05-.6.9 0 1.4.25 2.05.6.75.38 1.58.8 2.95.8v-1.95c-.9 0-1.4-.25-2.05-.6-.75-.38-1.6-.8-2.95-.8zm0-4.45c-1.35 0-2.2.43-2.95.8-.65.32-1.18.6-2.05.6-.9 0-1.4-.25-2.05-.6-.75-.38-1.57-.8-2.95-.8s-2.2.43-2.95.8c-.65.32-1.17.6-2.05.6v1.95c1.35 0 2.2-.43 2.95-.8.65-.35 1.15-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.43 2.95-.8c.65-.35 1.15-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.58.8 2.95.8v-1.95c-.9 0-1.4-.25-2.05-.6-.75-.38-1.6-.8-2.95-.8zm2.95-8.08c-.75-.38-1.58-.8-2.95-.8s-2.2.42-2.95.8c-.65.32-1.18.6-2.05.6-.9 0-1.4-.25-2.05-.6-.75-.37-1.57-.8-2.95-.8s-2.2.42-2.95.8c-.65.33-1.17.6-2.05.6v1.93c1.35 0 2.2-.43 2.95-.8.65-.33 1.17-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.43 2.95-.8c.65-.32 1.18-.6 2.05-.6.9 0 1.4.25 2.05.6.75.38 1.58.8 2.95.8V5.04c-.9 0-1.4-.25-2.05-.58zM17 8.09c-1.35 0-2.2.43-2.95.8-.65.35-1.15.6-2.05.6s-1.4-.25-2.05-.6c-.75-.38-1.57-.8-2.95-.8s-2.2.43-2.95.8c-.65.35-1.15.6-2.05.6v1.95c1.35 0 2.2-.43 2.95-.8.65-.32 1.18-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.43 2.95-.8c.65-.32 1.18-.6 2.05-.6.9 0 1.4.25 2.05.6.75.38 1.58.8 2.95.8V9.49c-.9 0-1.4-.25-2.05-.6-.75-.38-1.6-.8-2.95-.8z" /> + </g> + + <g id="lab" stroke="none"> + <path fill="none" d="M0 0h24v24H0V0z" /> + <path d="M13,11.33L18,18H6l5-6.67V6h2 M15.96,4H8.04C7.62,4,7.39,4.48,7.65,4.81L9,6.5v4.17L3.2,18.4C2.71,19.06,3.18,20,4,20h16 c0.82,0,1.29-0.94,0.8-1.6L15,10.67V6.5l1.35-1.69C16.61,4.48,16.38,4,15.96,4L15.96,4z" /> + </g> + <g id="search"> <path fill="none" d="M0 0h24v24H0V0z" stroke="none" /> <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" /> diff --git a/src/styles/_colors.scss b/src/styles/_colors.scss index 0319f9ab..3a291541 100644 --- a/src/styles/_colors.scss +++ b/src/styles/_colors.scss @@ -3,7 +3,7 @@ $red: #ff3344; $yellow: #ffd60f; $dark_blue: #3c75ff; -$blue: #3ca1ff; +$blue: #582cd0; $green: #00d2b9; //$green: #00503c; $olive: #8bc12a; diff --git a/src/styles/common/markdown.module.scss b/src/styles/common/markdown.module.scss index 9cfcb7e1..82cb22a5 100644 --- a/src/styles/common/markdown.module.scss +++ b/src/styles/common/markdown.module.scss @@ -55,6 +55,10 @@ $margin: 1em; p { margin-bottom: $margin; + + &:last-child { + margin-bottom: 0; + } } h5, h4, h3, h2, h1 { diff --git a/src/styles/variables.scss b/src/styles/variables.scss index 8577202c..a518e5d4 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -209,3 +209,13 @@ $sidebar_border: transparentize(white, 0.95); background: transparentize($content_bg, 0.4); box-shadow: transparentize(white, 0.95) -1px 0; } + +@mixin editor_round_button { + width: $upload_button_height; + height: $upload_button_height; + border-radius: ($upload_button_height / 2) !important; + flex: 0 0 $upload_button_height; + position: relative; + border-radius: $radius; + cursor: pointer; +} diff --git a/src/utils/hooks/node/useNodeAudios.ts b/src/utils/hooks/node/useNodeAudios.ts index 7ece487f..5dc043a1 100644 --- a/src/utils/hooks/node/useNodeAudios.ts +++ b/src/utils/hooks/node/useNodeAudios.ts @@ -3,6 +3,10 @@ import { useMemo } from 'react'; import { UPLOAD_TYPES } from '~/redux/uploads/constants'; export const useNodeAudios = (node: INode) => { + if (!node?.files) { + return []; + } + return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [ node.files, ]); diff --git a/src/utils/hooks/node/useNodeImages.ts b/src/utils/hooks/node/useNodeImages.ts index 4f6b71d5..375d6198 100644 --- a/src/utils/hooks/node/useNodeImages.ts +++ b/src/utils/hooks/node/useNodeImages.ts @@ -3,6 +3,10 @@ import { useMemo } from 'react'; import { UPLOAD_TYPES } from '~/redux/uploads/constants'; export const useNodeImages = (node: INode) => { + if (!node?.files) { + return []; + } + return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [ node.files, ]); diff --git a/src/utils/hooks/useInputPasteUpload.ts b/src/utils/hooks/useInputPasteUpload.ts new file mode 100644 index 00000000..dff574a1 --- /dev/null +++ b/src/utils/hooks/useInputPasteUpload.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect } from 'react'; +import { getImageFromPaste } from '~/utils/uploader'; + +// useInputPasteUpload attaches event listener to input, that calls onUpload if user pasted any image +export const useInputPasteUpload = ( + input: HTMLTextAreaElement | HTMLInputElement | undefined, + onUpload: (files: File[]) => void +) => { + const onPaste = useCallback(async event => { + const image = await getImageFromPaste(event); + + if (!image) return; + + onUpload([image]); + }, []); + + useEffect(() => { + if (!input) return; + + input.addEventListener('paste', onPaste); + + return () => input.removeEventListener('paste', onPaste); + }, [input, onPaste]); +}; diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts index c1ad5941..3ade4cf8 100644 --- a/src/utils/uploader.ts +++ b/src/utils/uploader.ts @@ -74,3 +74,37 @@ export const fakeUploader = ({ export const getFileType = (file: File): keyof typeof UPLOAD_TYPES | undefined => (file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) || undefined; + +// getImageFromPaste returns any images from paste event +export const getImageFromPaste = (event: ClipboardEvent): Promise<File | undefined> => { + const items = event.clipboardData?.items; + + return new Promise(resolve => { + for (let index in items) { + const item = items[index]; + + if (item.kind === 'file' && item.type.match(/^image\//)) { + const blob = item.getAsFile(); + const reader = new FileReader(); + const type = item.type; + + reader.onload = function(e) { + if (!e.target?.result) { + return; + } + + resolve( + new File([e.target?.result], 'paste.png', { + type, + lastModified: new Date().getTime(), + }) + ); + }; + + reader.readAsArrayBuffer(blob); + } + } + + // resolve(undefined); + }); +}; diff --git a/yarn.lock b/yarn.lock index f0c085db..ff033f49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1109,6 +1109,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.1.5": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d" + integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.10.5": version "7.13.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.7.tgz#d494e39d198ee9ca04f4dcb76d25d9d7a1dc961a" @@ -1587,6 +1594,14 @@ dependencies: "@babel/runtime" "^7.10.2" +"@tippy.js/react@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@tippy.js/react/-/react-3.1.1.tgz#027e4595e55f31430741fe8e0d92aaddfbe47efd" + integrity sha512-KF45vW/jKh/nBXk/2zzTFslv/T46zOMkIoDJ56ymZ+M00yHttk58J5wZ29oqGqDIUnobWSZD+cFpbR4u/UUvgw== + dependencies: + prop-types "^15.6.2" + tippy.js "^5.1.1" + "@types/aria-query@^4.2.0": version "4.2.0" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" @@ -1643,6 +1658,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/history@*": + version "4.7.8" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" + integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + "@types/hoist-non-react-statics@^3.3.0": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" @@ -1748,6 +1768,23 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-router-dom@^5.1.7": + version "5.1.7" + resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.7.tgz#a126d9ea76079ffbbdb0d9225073eb5797ab7271" + integrity sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-router" "*" + +"@types/react-router@*": + version "5.1.12" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.12.tgz#0f300e09468e7aed86e18241c90238c18c377e51" + integrity sha512-0bhXQwHYfMeJlCh7mGhc0VJTRm0Gk+Z8T00aiP4702mDUuLs9SMhnd2DitpjWFjdOecx2UXtICK14H9iMnziGA== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react@*": version "16.9.56" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.56.tgz#ea25847b53c5bec064933095fc366b1462e2adf0" @@ -2637,10 +2674,10 @@ bluebird@^3.5.5: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: - version "4.11.9" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" - integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== bn.js@^5.0.0, bn.js@^5.1.1: version "5.1.3" @@ -2716,7 +2753,7 @@ braces@~3.0.2: dependencies: fill-range "^7.0.1" -brorand@^1.0.1: +brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= @@ -4200,17 +4237,17 @@ electron-to-chromium@^1.3.378, electron-to-chromium@^1.3.591: integrity sha512-ctRyXD9y0mZu8pgeNwBUhLP3Guyr5YuqkfLKYmpTwYx7o9JtCEJme9JVX4xBXPr5ZNvr/iBXUvHLFEVJQThATg== elliptic@^6.5.3: - version "6.5.3" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" - integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== + version "6.5.4" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" + integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== dependencies: - bn.js "^4.4.0" - brorand "^1.0.1" + bn.js "^4.11.9" + brorand "^1.1.0" hash.js "^1.0.0" - hmac-drbg "^1.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" emoji-regex@^7.0.1, emoji-regex@^7.0.2: version "7.0.3" @@ -5469,7 +5506,7 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" -hmac-drbg@^1.0.0: +hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= @@ -7366,7 +7403,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: +minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= @@ -8323,6 +8360,11 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" +popper.js@^1.16.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" + integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== + portfinder@^1.0.26: version "1.0.28" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" @@ -9454,6 +9496,14 @@ react-sortable-hoc@^1.11: invariant "^2.2.4" prop-types "^15.5.7" +react-sticky-box@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-0.9.3.tgz#8450d4cef8e4fdd7b0351520365bc98c97da11af" + integrity sha512-Y/qO7vTqAvXuRR6G6ZCW4fX2Bz0GZRwiiLTVeZN5CVz9wzs37ev0Xj3KSKF/PzF0jifwATivI4t24qXG8rSz4Q== + dependencies: + "@babel/runtime" "^7.1.5" + resize-observer-polyfill "^1.5.1" + react@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" @@ -9780,6 +9830,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resize-sensor@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/resize-sensor/-/resize-sensor-0.0.6.tgz#75147dcb273de6832760e461d2e28de6dcf88c45" @@ -10952,6 +11007,13 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tippy.js@^5.1.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-5.2.1.tgz#e08d7332c103a15e427124d710d881fca82365d6" + integrity sha512-66UT6JRVn3dXNCORE+0UvUK3JZqV/VhLlU6HTDm3FmrweUUFUxUGvT8tUQ7ycMp+uhuLAwQw6dBabyC+iKf/MA== + dependencies: + popper.js "^1.16.0" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"