diff --git a/package.json b/package.json index 4374ead7..5ca469e5 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,13 @@ "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", "resize-sensor": "^0.0.6", "sticky-sidebar": "^3.3.1", + "swiper": "^6.5.0", "throttle-debounce": "^2.1.0", "typescript": "^4.0.5", "uuid4": "^1.1.4", @@ -70,6 +72,7 @@ "@types/node": "^11.13.22", "@types/ramda": "^0.26.33", "@types/react-redux": "^7.1.11", + "@types/swiper": "^5.4.2", "@types/yup": "^0.29.11", "craco-alias": "^2.1.1", "craco-fast-refresh": "^1.0.2", diff --git a/public/index.html b/public/index.html index d85dab5e..112c16fe 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ - + Убежище 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 = ({ 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 = ({ stats }) => { - {stats.git - .filter(data => data.commit && data.timestamp && data.subject) - .slice(0, 5) - .map(data => ( - - ))} + {open.map(data => ( + + ))} + + {closed.map(data => ( + + ))} ); }; 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; + data: IGithubIssue; } -const BorisStatsGitCard: FC = ({ data: { timestamp, subject } }) => { - if (!subject || !timestamp) return null; +const stateLabels: Record = { + open: 'Ожидает', + closed: 'Сделано', +}; + +const BorisStatsGitCard: FC = ({ data: { created_at, title, html_url, state } }) => { + if (!title || !created_at) return null; + + const date = useMemo(() => getPrettyDate(created_at), [created_at]); return (
- {getPrettyDate(new Date(parseInt(`${timestamp}000`)).toISOString())} + {stateLabels[state]} + {date}
-
{subject}
+ + {title} +
); }; 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/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 = 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 = memo( return (
- +
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 = ({ comment, nodeId, onCancelEdit }) => { }, [formik]); const error = formik.status || formik.errors.text; + useInputPasteUpload(textarea, uploader.uploadFiles); return ( @@ -65,34 +66,40 @@ const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => { - - +
+
+ +
- {!!textarea && ( - - )} +
+ {!!textarea && ( + + )} +
- {isLoading && } +
+ {isLoading && } - {isEditing && ( - + )} + + - )} - - - +
+
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 79d57d3c..dfeda268 100644 --- a/src/components/containers/Sticky/index.tsx +++ b/src/components/containers/Sticky/index.tsx @@ -1,32 +1,34 @@ 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'; +(window as any).ResizeSensor = ResizeSensor; + +import StickySidebar from 'sticky-sidebar'; +(window as any).StickySidebar = StickySidebar; + +import classnames from 'classnames'; interface IProps extends DetailsHTMLAttributes {} -(window as any).StickySidebar = StickySidebar; -(window as any).ResizeSensor = ResizeSensor; - const Sticky: FC = ({ children }) => { const ref = useRef(null); - let sb; + const sb = useRef(null); useEffect(() => { if (!ref.current) return; - sb = new StickySidebar(ref.current, { + sb.current = new StickySidebar(ref.current, { resizeSensor: true, topSpacing: 72, bottomSpacing: 10, }); - return () => sb.destroy(); - }, [ref.current, children]); + return () => sb.current?.destroy(); + }, [ref.current, sb.current, children]); if (sb) { - sb.updateSticky(); + sb.current?.updateSticky(); } return ( diff --git a/src/components/editors/AudioEditor/index.tsx b/src/components/editors/AudioEditor/index.tsx index 94acddc4..2ac1119a 100644 --- a/src/components/editors/AudioEditor/index.tsx +++ b/src/components/editors/AudioEditor/index.tsx @@ -8,6 +8,8 @@ import { selectUploads } from '~/redux/uploads/selectors'; import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; import styles from './styles.module.scss'; import { NodeEditorProps } from '~/redux/node/types'; +import { useNodeImages } from '~/utils/hooks/node/useNodeImages'; +import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios'; const mapStateToProps = selectUploads; const mapDispatchToProps = { @@ -17,10 +19,7 @@ const mapDispatchToProps = { type IProps = ReturnType & typeof mapDispatchToProps & NodeEditorProps; const AudioEditorUnconnected: FC = ({ data, setData, temp, statuses }) => { - const images = useMemo( - () => data.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), - [data.files] - ); + const images = useNodeImages(data); const pending_images = useMemo( () => @@ -30,10 +29,7 @@ const AudioEditorUnconnected: FC = ({ data, setData, temp, statuses }) = [temp, statuses] ); - const audios = useMemo( - () => data.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), - [data.files] - ); + const audios = useNodeAudios(data); const pending_audios = useMemo( () => 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 = ({ recent, updated }) => { return ( <> {updated && updated.map(node => )} - {recent && recent.map(node => )} ); diff --git a/src/components/input/Button/styles.module.scss b/src/components/input/Button/styles.module.scss index 667007bd..255b3594 100644 --- a/src/components/input/Button/styles.module.scss +++ b/src/components/input/Button/styles.module.scss @@ -44,7 +44,6 @@ @include outer_shadow(); input { - color: red; position: absolute; top: 0; left: 0; diff --git a/src/components/node/NodeBottomBlock/index.tsx b/src/components/node/NodeBottomBlock/index.tsx new file mode 100644 index 00000000..d06da73c --- /dev/null +++ b/src/components/node/NodeBottomBlock/index.tsx @@ -0,0 +1,75 @@ +import React, { FC } from 'react'; +import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; +import { Group } from '~/components/containers/Group'; +import { Padder } from '~/components/containers/Padder'; +import { NodeCommentsBlock } from '~/components/node/NodeCommentsBlock'; +import { NodeCommentForm } from '~/components/node/NodeCommentForm'; +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; + isLoading: boolean; + commentsOrder: 'ASC' | 'DESC'; + comments: IComment[]; + commentsCount: number; + isLoadingComments: boolean; + related: INodeRelated; +} + +const NodeBottomBlock: FC = ({ + node, + isLoading, + isLoadingComments, + comments, + commentsCount, + commentsOrder, + related, +}) => { + const { inline } = useNodeBlocks(node, isLoading); + const { is_user } = useUser(); + + if (node.deleted_at) { + return ; + } + + return ( + + + + + {inline &&
{inline}
} + + + + {is_user && !isLoading && } +
+ +
+ + + + + + +
+
+
+
+ ); +}; + +export { NodeBottomBlock }; 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/NodeCommentsBlock/index.tsx b/src/components/node/NodeCommentsBlock/index.tsx new file mode 100644 index 00000000..f111dbff --- /dev/null +++ b/src/components/node/NodeCommentsBlock/index.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { NodeNoComments } from '~/components/node/NodeNoComments'; +import { NodeComments } from '~/components/node/NodeComments'; +import { IComment, INode } from '~/redux/types'; +import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks'; +import { useUser } from '~/utils/hooks/user/userUser'; + +interface IProps { + order: 'ASC' | 'DESC'; + node: INode; + comments: IComment[]; + count: number; + isLoading: boolean; + isLoadingComments: boolean; +} + +const NodeCommentsBlock: FC = ({ isLoading, isLoadingComments, node, comments, count }) => { + const user = useUser(); + const { inline } = useNodeBlocks(node, isLoading); + + return isLoading || isLoadingComments || (!comments.length && !inline) ? ( + + ) : ( + + ); +}; + +export { NodeCommentsBlock }; diff --git a/src/components/node/NodeImageSlideBlock/index.tsx b/src/components/node/NodeImageSlideBlock/index.tsx index 85c5cbbd..746dde08 100644 --- a/src/components/node/NodeImageSlideBlock/index.tsx +++ b/src/components/node/NodeImageSlideBlock/index.tsx @@ -8,21 +8,24 @@ import { PRESETS } from '~/constants/urls'; import { throttle } from 'throttle-debounce'; import { Icon } from '~/components/input/Icon'; import { useArrows } from '~/utils/hooks/keys'; +import { useDispatch } from 'react-redux'; +import { modalShowPhotoswipe } from '~/redux/modal/actions'; +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { selectModal } from '~/redux/modal/selectors'; -interface IProps extends INodeComponentProps {} +interface IProps extends INodeComponentProps { + updateLayout?: () => void; +} const getX = event => (event.touches && event.touches.length) || (event.changedTouches && event.changedTouches.length) ? (event.touches.length && event.touches[0].clientX) || event.changedTouches[0].clientX : event.clientX; -const NodeImageSlideBlock: FC = ({ - node, - is_loading, - is_modal_shown, - updateLayout, - modalShowPhotoswipe, -}) => { +const NodeImageSlideBlock: FC = ({ node, isLoading, updateLayout = () => {} }) => { + const dispatch = useDispatch(); + const { is_shown } = useShallowSelect(selectModal); + const [current, setCurrent] = useState(0); const [height, setHeight] = useState(window.innerHeight - 143); const [max_height, setMaxHeight] = useState(960); @@ -88,7 +91,7 @@ const NodeImageSlideBlock: FC = ({ const { width } = wrap.current.getBoundingClientRect(); const fallback = window.innerHeight - 143; - if (is_loading) { + if (isLoading) { setHeight(fallback); return () => clearTimeout(timeout); } @@ -118,7 +121,7 @@ const NodeImageSlideBlock: FC = ({ return () => { if (timeout) clearTimeout(timeout); }; - }, [is_dragging, wrap, offset, heights, max_height, images, is_loading, updateLayout]); + }, [is_dragging, wrap, offset, heights, max_height, images, isLoading, updateLayout]); const onDrag = useCallback( event => { @@ -162,8 +165,8 @@ const NodeImageSlideBlock: FC = ({ normalizeOffset(); }, [wrap, setMaxHeight, normalizeOffset]); - const onOpenPhotoSwipe = useCallback(() => modalShowPhotoswipe(images, current), [ - modalShowPhotoswipe, + const onOpenPhotoSwipe = useCallback(() => dispatch(modalShowPhotoswipe(images, current)), [ + dispatch, images, current, ]); @@ -241,7 +244,7 @@ const NodeImageSlideBlock: FC = ({ images, ]); - useArrows(onNext, onPrev, is_modal_shown); + useArrows(onNext, onPrev, is_shown); useEffect(() => { setOffset(0); @@ -249,7 +252,7 @@ const NodeImageSlideBlock: FC = ({ return (
-
+
= ({ onTouchStart={startDragging} ref={slide} > - {!is_loading && + {!isLoading && images.map((file, index) => (
= ({ node }) => { + const dispatch = useDispatch(); + const [controlledSwiper, setControlledSwiper] = useState(undefined); + + const images = useNodeImages(node); + + const updateSwiper = useCallback(() => { + if (!controlledSwiper) return; + + controlledSwiper.updateSlides(); + controlledSwiper.updateSize(); + controlledSwiper.update(); + }, [controlledSwiper]); + + const resetSwiper = useCallback(() => { + if (!controlledSwiper) return; + controlledSwiper.slideTo(0, 0); + setTimeout(() => controlledSwiper.slideTo(0, 0), 300); + }, [controlledSwiper]); + + useEffect(() => { + updateSwiper(); + resetSwiper(); + }, [images, updateSwiper, resetSwiper]); + + const onOpenPhotoSwipe = useCallback( + () => dispatch(modalShowPhotoswipe(images, controlledSwiper?.activeIndex || 0)), + [dispatch, images, controlledSwiper] + ); + + if (!images?.length) { + return null; + } + + return ( +
+ + {images.map(file => ( + + {node.title} + + ))} + +
+ ); +}; + +export { NodeImageSwiperBlock }; diff --git a/src/components/node/NodeImageSwiperBlock/styles.module.scss b/src/components/node/NodeImageSwiperBlock/styles.module.scss new file mode 100644 index 00000000..6dd0a49b --- /dev/null +++ b/src/components/node/NodeImageSwiperBlock/styles.module.scss @@ -0,0 +1,85 @@ +@import "~/styles/variables.scss"; + +.wrapper { + border-radius: $radius; + display: flex; + align-items: center; + justify-content: center; + + :global(.swiper-pagination) { + left: 50%; + bottom: $gap * 2; + transform: translate(-50%, 0); + background: darken($comment_bg, 4%); + width: auto; + padding: 5px 10px; + border-radius: 10px; + font: $font_10_semibold; + } + + :global(.swiper-container) { + width: 100vw; + } + + :global(.swiper-button-next), + :global(.swiper-button-prev) { + color: white; + font-size: 10px; + + &::after { + font-size: 32px; + } + } + +} + +.slide { + text-align: center; + text-transform: uppercase; + font: $font_32_bold; + display: flex; + border-radius: $radius; + align-items: center; + justify-content: center; + width: auto; + max-width: 100vw; + opacity: 1; + transform: translate(0, 10px); + filter: brightness(50%) saturate(0.5); + transition: opacity 0.5s, filter 0.5s, transform 0.5s; + padding-bottom: $gap * 1.5; + padding-top: $gap; + + &:global(.swiper-slide-active) { + opacity: 1; + filter: brightness(100%); + transform: translate(0, 0); + } + + @include tablet { + padding-bottom: 0; + padding-top: 0; + transform: translate(0, 0); + } +} + +.image { + max-height: calc(100vh - 70px - 70px); + max-width: 100%; + border-radius: $radius; + transition: box-shadow 1s; + box-shadow: transparentize(black, 0.7) 0 3px 5px; + + :global(.swiper-slide-active) & { + box-shadow: transparentize(black, 0.9) 0 10px 5px 4px, + transparentize(black, 0.7) 0 5px 5px, + transparentize(white, 0.95) 0 -1px 2px, + transparentize(white, 0.95) 0 -1px; + } + + @include tablet { + padding-bottom: 0; + max-height: 100vh; + border-radius: 0; + } +} diff --git a/src/components/node/NodePanel/index.tsx b/src/components/node/NodePanel/index.tsx index 81530828..ab83d01c 100644 --- a/src/components/node/NodePanel/index.tsx +++ b/src/components/node/NodePanel/index.tsx @@ -1,85 +1,35 @@ -import React, { FC, useCallback, useEffect, useRef, useState, memo } from 'react'; +import React, { FC, memo } from 'react'; import styles from './styles.module.scss'; import { INode } from '~/redux/types'; -import { createPortal } from 'react-dom'; import { NodePanelInner } from '~/components/node/NodePanelInner'; +import { useNodePermissions } from '~/utils/hooks/node/useNodePermissions'; +import { useNodeActions } from '~/utils/hooks/node/useNodeActions'; +import { shallowEqual } from 'react-redux'; interface IProps { - node: Partial; - layout: {}; - - can_edit: boolean; - can_like: boolean; - can_star: boolean; - - is_loading?: boolean; - - onEdit: () => void; - onLike: () => void; - onStar: () => void; - onLock: () => void; + node: INode; + isLoading: boolean; } -const NodePanel: FC = memo( - ({ node, layout, can_edit, can_like, can_star, is_loading, onEdit, onLike, onStar, onLock }) => { - const [stack, setStack] = useState(false); +const NodePanel: FC = memo(({ node, isLoading }) => { + const [can_edit, can_like, can_star] = useNodePermissions(node); + const { onEdit, onLike, onStar, onLock } = useNodeActions(node); - const ref = useRef(null); - const getPlace = useCallback(() => { - if (!ref.current) return; - - const { bottom } = ref.current!.getBoundingClientRect(); - - setStack(bottom > window.innerHeight); - }, [ref]); - - useEffect(() => getPlace(), [layout]); - - useEffect(() => { - window.addEventListener('scroll', getPlace); - window.addEventListener('resize', getPlace); - - return () => { - window.removeEventListener('scroll', getPlace); - window.removeEventListener('resize', getPlace); - }; - }, [layout, getPlace]); - - return ( -
- {/* - stack && - createPortal( - , - document.body - ) - */} - - -
- ); - } -); + return ( +
+ +
+ ); +}, shallowEqual); export { NodePanel }; diff --git a/src/components/node/NodePanelInner/index.tsx b/src/components/node/NodePanelInner/index.tsx index 41e66d05..0a10c1b2 100644 --- a/src/components/node/NodePanelInner/index.tsx +++ b/src/components/node/NodePanelInner/index.tsx @@ -1,7 +1,5 @@ import React, { FC, memo } from 'react'; import styles from './styles.module.scss'; -import { Group } from '~/components/containers/Group'; -import { Filler } from '~/components/containers/Filler'; import { Icon } from '~/components/input/Icon'; import { INode } from '~/redux/types'; import classNames from 'classnames'; @@ -12,11 +10,11 @@ interface IProps { node: Partial; stack?: boolean; - can_edit: boolean; - can_like: boolean; - can_star: boolean; + canEdit: boolean; + canLike: boolean; + canStar: boolean; - is_loading: boolean; + isLoading: boolean; onEdit: () => void; onLike: () => void; @@ -29,11 +27,11 @@ const NodePanelInner: FC = memo( node: { title, user, is_liked, is_heroic, deleted_at, created_at, like_count }, stack, - can_star, - can_edit, - can_like, + canStar, + canEdit, + canLike, - is_loading, + isLoading, onStar, onEdit, @@ -45,12 +43,12 @@ const NodePanelInner: FC = memo(
- {is_loading ? : title || '...'} + {isLoading ? : title || '...'}
{user && user.username && (
- {is_loading ? ( + {isLoading ? ( ) : ( `~${user.username.toLocaleLowerCase()}, ${getPrettyDate(created_at)}` @@ -59,14 +57,14 @@ const NodePanelInner: FC = memo( )}
- {can_edit && ( + {canEdit && (
- {can_star && ( + {canStar && (
{is_heroic ? ( @@ -88,7 +86,7 @@ const NodePanelInner: FC = memo( )}
- {can_like && ( + {canLike && (
{is_liked ? ( diff --git a/src/components/node/NodeRelated/styles.module.scss b/src/components/node/NodeRelated/styles.module.scss index a5d06d58..ff4cbb95 100644 --- a/src/components/node/NodeRelated/styles.module.scss +++ b/src/components/node/NodeRelated/styles.module.scss @@ -16,7 +16,7 @@ grid-row-gap: $gap; @include tablet { - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(3, 1fr); } } diff --git a/src/components/node/NodeRelatedBlock/index.tsx b/src/components/node/NodeRelatedBlock/index.tsx new file mode 100644 index 00000000..c4948cb7 --- /dev/null +++ b/src/components/node/NodeRelatedBlock/index.tsx @@ -0,0 +1,44 @@ +import React, { FC } from 'react'; +import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder'; +import { NodeRelated } from '~/components/node/NodeRelated'; +import { URLS } from '~/constants/urls'; +import { INode } from '~/redux/types'; +import { INodeRelated } from '~/redux/node/types'; +import { Link } from 'react-router-dom'; + +interface IProps { + isLoading: boolean; + node: INode; + related: INodeRelated; +} + +const NodeRelatedBlock: FC = ({ isLoading, node, related }) => { + if (isLoading) { + return ; + } + + return ( +
+ {related && + related.albums && + !!node?.id && + Object.keys(related.albums) + .filter(album => related.albums[album].length > 0) + .map(album => ( + {album} + } + items={related.albums[album]} + key={album} + /> + ))} + + {related && related.similar && related.similar.length > 0 && ( + + )} +
+ ); +}; + +export { NodeRelatedBlock }; diff --git a/src/components/node/NodeTagsBlock/index.tsx b/src/components/node/NodeTagsBlock/index.tsx new file mode 100644 index 00000000..e4ee877f --- /dev/null +++ b/src/components/node/NodeTagsBlock/index.tsx @@ -0,0 +1,52 @@ +import React, { FC, useCallback } from 'react'; +import { INode, ITag } from '~/redux/types'; +import { URLS } from '~/constants/urls'; +import { nodeUpdateTags } from '~/redux/node/actions'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router'; +import { NodeTags } from '~/components/node/NodeTags'; +import { useUser } from '~/utils/hooks/user/userUser'; + +interface IProps { + node: INode; + isLoading: boolean; +} + +const NodeTagsBlock: FC = ({ node, isLoading }) => { + const dispatch = useDispatch(); + const history = useHistory(); + const { is_user } = useUser(); + + const onTagsChange = useCallback( + (tags: string[]) => { + dispatch(nodeUpdateTags(node.id, tags)); + }, + [dispatch, node] + ); + + const onTagClick = useCallback( + (tag: Partial) => { + if (!node?.id || !tag?.title) { + return; + } + + history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title))); + }, + [history, node] + ); + + if (isLoading) { + return null; + } + + return ( + + ); +}; + +export { NodeTagsBlock }; diff --git a/src/containers/flow/FlowLayout/index.tsx b/src/containers/flow/FlowLayout/index.tsx index 179781b6..cf263080 100644 --- a/src/containers/flow/FlowLayout/index.tsx +++ b/src/containers/flow/FlowLayout/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect, useCallback } from 'react'; +import React, { FC, useCallback, useEffect } from 'react'; import { connect } from 'react-redux'; import { FlowGrid } from '~/components/flow/FlowGrid'; import { selectFlow } from '~/redux/flow/selectors'; @@ -10,6 +10,7 @@ import { FlowHero } from '~/components/flow/FlowHero'; import styles from './styles.module.scss'; import { IState } from '~/redux/store'; import { FlowStamp } from '~/components/flow/FlowStamp'; +import { Container } from '~/containers/main/Container'; const mapStateToProps = (state: IState) => ({ flow: pick(['nodes', 'heroes', 'recent', 'updated', 'is_loading', 'search'], selectFlow(state)), @@ -61,28 +62,30 @@ const FlowLayoutUnconnected: FC = ({ }, []); return ( -
-
- -
+ +
+
+ +
-
- + +
+ +
- - -
+ ); }; diff --git a/src/containers/main/Container/index.tsx b/src/containers/main/Container/index.tsx new file mode 100644 index 00000000..d5045fd1 --- /dev/null +++ b/src/containers/main/Container/index.tsx @@ -0,0 +1,13 @@ +import React, { FC } from 'react'; +import styles from './styles.module.scss'; +import classNames from 'classnames'; + +interface IProps { + className?: string; +} + +const Container: FC = ({ className, children }) => ( +
{children}
+); + +export { Container }; diff --git a/src/containers/main/Container/styles.module.scss b/src/containers/main/Container/styles.module.scss new file mode 100644 index 00000000..736fa115 --- /dev/null +++ b/src/containers/main/Container/styles.module.scss @@ -0,0 +1,16 @@ +@import "~/styles/variables.scss"; + +.container { + width: 100%; + max-width: $content_width; + margin: auto; + padding: 0 $gap; + + @include tablet { + padding: 0; + } + + @media (max-width: $content_width + $gap * 4) { + padding: 0; + } +} diff --git a/src/containers/main/MainLayout/styles.module.scss b/src/containers/main/MainLayout/styles.module.scss index e87b6e31..79ec32f8 100644 --- a/src/containers/main/MainLayout/styles.module.scss +++ b/src/containers/main/MainLayout/styles.module.scss @@ -2,24 +2,18 @@ .wrapper { width: 100%; - padding: 0 $gap; display: flex; flex-direction: column; box-sizing: border-box; align-items: center; justify-content: flex-start; flex: 1; - - @include tablet { - padding: 0; - } } .content { flex: 1; position: relative; width: 100%; - max-width: $content_width; display: flex; padding-bottom: 29px; flex-direction: column; diff --git a/src/containers/node/BorisLayout/index.tsx b/src/containers/node/BorisLayout/index.tsx index 49ef20d2..d2593ad4 100644 --- a/src/containers/node/BorisLayout/index.tsx +++ b/src/containers/node/BorisLayout/index.tsx @@ -12,13 +12,14 @@ 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 { 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'; type IProps = {}; @@ -55,59 +56,61 @@ const BorisLayout: FC = () => { }, [dispatch]); return ( -
-
+ +
+
-
-
-
{title}
+
+
+
{title}
+
+ + Борис
- Борис -
+
+ + + {user.is_user && } -
- - - {user.is_user && } - - {node.is_loading_comments ? ( - - ) : ( - - )} - - -
- - - - - -
-

Господи-боженьки, где это я?

- -

- Всё впорядке, это — главный штаб Суицидальных Роботов, строителей Убежища. -

-

Здесь мы сидим и слушаем всё, что вас беспокоит.

-

Все виновные будут наказаны. Невиновные, впрочем, тоже.

-

// Такова жизнь.

-
- -
- -
+ {node.is_loading_comments ? ( + + ) : ( + + )}
-
-
+ +
+ + + + + +
+

Господи-боженьки, где это я?

+ +

+ Всё впорядке, это — главный штаб Суицидальных Роботов, строителей Убежища. +

+

Здесь мы сидим и слушаем всё, что вас беспокоит.

+

Все виновные будут наказаны. Невиновные, впрочем, тоже.

+

// Такова жизнь.

+
+ +
+ +
+
+
+
+
-
+ ); }; diff --git a/src/containers/node/NodeLayout/index.tsx b/src/containers/node/NodeLayout/index.tsx index 00c3ef7c..35dd2cf2 100644 --- a/src/containers/node/NodeLayout/index.tsx +++ b/src/containers/node/NodeLayout/index.tsx @@ -1,256 +1,72 @@ -import React, { createElement, FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { RouteComponentProps, useHistory } from 'react-router'; -import { connect } from 'react-redux'; -import { canEditNode, canLikeNode, canStarNode } from '~/utils/node'; +import React, { FC, memo } from 'react'; +import { RouteComponentProps } from 'react-router'; import { selectNode } from '~/redux/node/selectors'; import { Card } from '~/components/containers/Card'; import { NodePanel } from '~/components/node/NodePanel'; -import { Group } from '~/components/containers/Group'; -import { Padder } from '~/components/containers/Padder'; -import { NodeNoComments } from '~/components/node/NodeNoComments'; -import { NodeRelated } from '~/components/node/NodeRelated'; -import { NodeComments } from '~/components/node/NodeComments'; -import { NodeTags } from '~/components/node/NodeTags'; -import { - INodeComponentProps, - NODE_COMPONENTS, - NODE_HEADS, - NODE_INLINES, -} from '~/redux/node/constants'; -import { selectUser } from '~/redux/auth/selectors'; -import { path, pick, prop } from 'ramda'; -import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder'; -import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; -import { NodeCommentForm } from '~/components/node/NodeCommentForm'; -import { Sticky } from '~/components/containers/Sticky'; import { Footer } from '~/components/main/Footer'; -import { Link } from 'react-router-dom'; import styles from './styles.module.scss'; -import * as NODE_ACTIONS from '~/redux/node/actions'; -import * as MODAL_ACTIONS from '~/redux/modal/actions'; -import { IState } from '~/redux/store'; -import { selectModal } from '~/redux/modal/selectors'; import { SidebarRouter } from '~/containers/main/SidebarRouter'; -import { ITag } from '~/redux/types'; -import { URLS } from '~/constants/urls'; import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { Container } from '~/containers/main/Container'; +import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks'; +import { NodeBottomBlock } from '~/components/node/NodeBottomBlock'; +import { useNodeCoverImage } from '~/utils/hooks/node/useNodeCoverImage'; +import { useScrollToTop } from '~/utils/hooks/useScrollToTop'; +import { useLoadNode } from '~/utils/hooks/node/useLoadNode'; -const mapStateToProps = (state: IState) => ({ - node: selectNode(state), - user: selectUser(state), - modal: pick(['is_shown'])(selectModal(state)), -}); +type IProps = RouteComponentProps<{ id: string }> & {}; -const mapDispatchToProps = { - nodeGotoNode: NODE_ACTIONS.nodeGotoNode, - nodeUpdateTags: NODE_ACTIONS.nodeUpdateTags, - nodeSetCoverImage: NODE_ACTIONS.nodeSetCoverImage, - nodeEdit: NODE_ACTIONS.nodeEdit, - nodeLike: NODE_ACTIONS.nodeLike, - nodeStar: NODE_ACTIONS.nodeStar, - nodeLock: NODE_ACTIONS.nodeLock, - nodeLockComment: NODE_ACTIONS.nodeLockComment, - nodeEditComment: NODE_ACTIONS.nodeEditComment, - nodeLoadMoreComments: NODE_ACTIONS.nodeLoadMoreComments, - modalShowPhotoswipe: MODAL_ACTIONS.modalShowPhotoswipe, -}; - -type IProps = ReturnType & - typeof mapDispatchToProps & - RouteComponentProps<{ id: string }> & {}; - -const NodeLayoutUnconnected: FC = memo( +const NodeLayout: FC = memo( ({ match: { params: { id }, }, - modal: { is_shown: is_modal_shown }, - user, - user: { is_user }, - nodeGotoNode, - nodeUpdateTags, - nodeEdit, - nodeLike, - nodeStar, - nodeLock, - nodeSetCoverImage, - modalShowPhotoswipe, }) => { - const [layout, setLayout] = useState({}); - const history = useHistory(); const { is_loading, - is_loading_comments, - comments = [], - current: node, - related, + current, + comments, comment_count, + is_loading_comments, + related, } = useShallowSelect(selectNode); - const updateLayout = useCallback(() => setLayout({}), []); - useEffect(() => { - if (is_loading) return; - nodeGotoNode(parseInt(id, 10), null); - }, [nodeGotoNode, id]); + useNodeCoverImage(current); + useScrollToTop([id]); + useLoadNode(id, is_loading); - const onTagsChange = useCallback( - (tags: string[]) => { - nodeUpdateTags(node.id, tags); - }, - [node, nodeUpdateTags] - ); - - const onTagClick = useCallback( - (tag: Partial) => { - if (!node?.id || !tag?.title) { - return; - } - - history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title))); - }, - [history, node.id] - ); - - const can_edit = useMemo(() => canEditNode(node, user), [node, user]); - const can_like = useMemo(() => canLikeNode(node, user), [node, user]); - const can_star = useMemo(() => canStarNode(node, user), [node, user]); - - const head = useMemo(() => node?.type && prop(node?.type, NODE_HEADS), [node.type]); - const block = useMemo(() => node?.type && prop(node?.type, NODE_COMPONENTS), [node.type]); - const inline = useMemo(() => node?.type && prop(node?.type, NODE_INLINES), [node.type]); - - const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]); - const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]); - const onStar = useCallback(() => nodeStar(node.id), [nodeStar, node]); - const onLock = useCallback(() => nodeLock(node.id, !node.deleted_at), [nodeStar, node]); - - const createNodeBlock = useCallback( - (block: FC) => - block && - createElement(block, { - node, - is_loading, - updateLayout, - layout, - modalShowPhotoswipe, - is_modal_shown, - }), - [node, is_loading, updateLayout, layout, modalShowPhotoswipe, is_modal_shown] - ); - - useEffect(() => { - if (!node.cover) return; - nodeSetCoverImage(node.cover); - return () => nodeSetCoverImage(null); - }, [nodeSetCoverImage, node.cover]); - - useEffect(() => { - window.scrollTo(0, 0); - }, [id]); + const { head, block } = useNodeBlocks(current, is_loading); return ( - <> - {!!head && createNodeBlock(head)} +
+ {head} - - {!!block && createNodeBlock(block)} + + + {block} - + - {node.deleted_at ? ( - - ) : ( - - - - - {inline &&
{createNodeBlock(inline)}
} + - {is_loading || is_loading_comments || (!comments.length && !inline) ? ( - - ) : ( - - )} - - {is_user && !is_loading && } -
- -
- - - {!is_loading && ( - - )} - - {is_loading && } - - {!is_loading && - related && - related.albums && - !!node?.id && - Object.keys(related.albums) - .filter(album => related.albums[album].length > 0) - .map(album => ( - - {album} - - } - items={related.albums[album]} - key={album} - /> - ))} - - {!is_loading && - related && - related.similar && - related.similar.length > 0 && ( - - )} - - -
-
-
-
- )} - -
- +
+ + - +
); } ); -const NodeLayout = connect(mapStateToProps, mapDispatchToProps)(NodeLayoutUnconnected); - -export { NodeLayout, NodeLayoutUnconnected }; +export { NodeLayout }; 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/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(git); export const getBorisBackendStats = () => api.get(API.BORIS.GET_BACKEND_STATS).then(cleanResult); + +export const getGithubIssues = () => { + return axios + .get('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[]; + 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 = yield call(getBorisGitStats); const backend: Unwrap = yield call(getBorisBackendStats); + const issues: Unwrap = 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/node/actions.ts b/src/redux/node/actions.ts index 05012f98..1faa62df 100644 --- a/src/redux/node/actions.ts +++ b/src/redux/node/actions.ts @@ -129,7 +129,7 @@ export const nodeSetEditor = (editor: INode) => ({ editor, }); -export const nodeSetCoverImage = (current_cover_image: IFile) => ({ +export const nodeSetCoverImage = (current_cover_image?: IFile) => ({ type: NODE_ACTIONS.SET_COVER_IMAGE, current_cover_image, }); diff --git a/src/redux/node/constants.ts b/src/redux/node/constants.ts index dfc3bd3e..8cc79869 100644 --- a/src/redux/node/constants.ts +++ b/src/redux/node/constants.ts @@ -1,6 +1,5 @@ -import { FC, ReactElement } from 'react'; +import { FC } from 'react'; import { IComment, INode, ValueOf } from '../types'; -import { NodeImageSlideBlock } from '~/components/node/NodeImageSlideBlock'; import { NodeTextBlock } from '~/components/node/NodeTextBlock'; import { NodeAudioBlock } from '~/components/node/NodeAudioBlock'; import { NodeVideoBlock } from '~/components/node/NodeVideoBlock'; @@ -12,10 +11,10 @@ import { AudioEditor } from '~/components/editors/AudioEditor'; import { EditorImageUploadButton } from '~/components/editors/EditorImageUploadButton'; import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadButton'; import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton'; -import { modalShowPhotoswipe } from '../modal/actions'; 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.'; export const NODE_ACTIONS = { @@ -79,17 +78,13 @@ export const NODE_TYPES = { export type INodeComponentProps = { node: INode; - is_loading: boolean; - is_modal_shown: boolean; - layout: {}; - updateLayout: () => void; - modalShowPhotoswipe: typeof modalShowPhotoswipe; + isLoading: boolean; }; export type INodeComponents = Record, FC>; export const NODE_HEADS: INodeComponents = { - [NODE_TYPES.IMAGE]: NodeImageSlideBlock, + [NODE_TYPES.IMAGE]: NodeImageSwiperBlock, }; export const NODE_COMPONENTS: INodeComponents = { diff --git a/src/redux/node/reducer.ts b/src/redux/node/reducer.ts index 438524cd..b72b1682 100644 --- a/src/redux/node/reducer.ts +++ b/src/redux/node/reducer.ts @@ -1,16 +1,14 @@ import { createReducer } from '~/utils/reducer'; -import { INode, IComment, IFile } from '../types'; -import { EMPTY_NODE, EMPTY_COMMENT } from './constants'; +import { IComment, IFile, INode } from '../types'; +import { EMPTY_COMMENT, EMPTY_NODE } from './constants'; import { NODE_HANDLERS } from './handlers'; +import { INodeRelated } from '~/redux/node/types'; export type INodeState = Readonly<{ editor: INode; current: INode; comments: IComment[]; - related: { - albums: Record; - similar: INode[]; - }; + related: INodeRelated; comment_data: Record; comment_count: number; current_cover_image?: IFile; diff --git a/src/redux/node/types.ts b/src/redux/node/types.ts index 4dba0cc3..33c06955 100644 --- a/src/redux/node/types.ts +++ b/src/redux/node/types.ts @@ -89,3 +89,8 @@ export type NodeEditorProps = { temp: string[]; setTemp: (val: string[]) => void; }; + +export type INodeRelated = { + albums: Record; + similar: INode[]; +}; diff --git a/src/redux/store.ts b/src/redux/store.ts index eb9c60ff..ea16f709 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -75,7 +75,9 @@ export const sagaMiddleware = createSagaMiddleware(); export const history = createBrowserHistory(); const composeEnhancers = - typeof window === 'object' && (window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ + typeof window === 'object' && + (window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && + process.env.NODE_ENV === 'development' ? (window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose; 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) { - yield put(tagSetNodes({ isLoading: true, list: [] })); + yield put(tagSetNodes({ isLoading: true })); try { const { list }: ReturnType = yield select(selectTagNodes); 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/utils/hooks/node/useLoadNode.ts b/src/utils/hooks/node/useLoadNode.ts new file mode 100644 index 00000000..6d21392a --- /dev/null +++ b/src/utils/hooks/node/useLoadNode.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { nodeGotoNode } from '~/redux/node/actions'; +import { useDispatch } from 'react-redux'; + +// useLoadNode loads node on id change +export const useLoadNode = (id: any, isLoading: boolean) => { + const dispatch = useDispatch(); + + useEffect(() => { + if (isLoading) return; + dispatch(nodeGotoNode(parseInt(id, 10), undefined)); + }, [dispatch, id]); +}; diff --git a/src/utils/hooks/node/useNodeActions.ts b/src/utils/hooks/node/useNodeActions.ts new file mode 100644 index 00000000..cd3bb124 --- /dev/null +++ b/src/utils/hooks/node/useNodeActions.ts @@ -0,0 +1,19 @@ +import { INode } from '~/redux/types'; +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { nodeEdit, nodeLike, nodeLock, nodeStar } from '~/redux/node/actions'; + +export const useNodeActions = (node: INode) => { + const dispatch = useDispatch(); + + const onEdit = useCallback(() => dispatch(nodeEdit(node.id)), [dispatch, nodeEdit, node]); + const onLike = useCallback(() => dispatch(nodeLike(node.id)), [dispatch, nodeLike, node]); + const onStar = useCallback(() => dispatch(nodeStar(node.id)), [dispatch, nodeStar, node]); + const onLock = useCallback(() => dispatch(nodeLock(node.id, !node.deleted_at)), [ + dispatch, + nodeLock, + node, + ]); + + return { onEdit, onLike, onStar, onLock }; +}; diff --git a/src/utils/hooks/node/useNodeAudios.ts b/src/utils/hooks/node/useNodeAudios.ts new file mode 100644 index 00000000..7ece487f --- /dev/null +++ b/src/utils/hooks/node/useNodeAudios.ts @@ -0,0 +1,9 @@ +import { INode } from '~/redux/types'; +import { useMemo } from 'react'; +import { UPLOAD_TYPES } from '~/redux/uploads/constants'; + +export const useNodeAudios = (node: INode) => { + return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [ + node.files, + ]); +}; diff --git a/src/utils/hooks/node/useNodeBlocks.ts b/src/utils/hooks/node/useNodeBlocks.ts new file mode 100644 index 00000000..823e522c --- /dev/null +++ b/src/utils/hooks/node/useNodeBlocks.ts @@ -0,0 +1,39 @@ +import { INode } from '~/redux/types'; +import { createElement, FC, useCallback, useMemo } from 'react'; +import { isNil, prop } from 'ramda'; +import { + INodeComponentProps, + NODE_COMPONENTS, + NODE_HEADS, + NODE_INLINES, +} from '~/redux/node/constants'; + +// useNodeBlocks returns head, block and inline blocks of node +export const useNodeBlocks = (node: INode, isLoading: boolean) => { + const createNodeBlock = useCallback( + (block?: FC) => + !isNil(block) && + createElement(block, { + node, + isLoading, + }), + [node, isLoading] + ); + + const head = useMemo( + () => createNodeBlock(node?.type ? prop(node?.type, NODE_HEADS) : undefined), + [node, createNodeBlock] + ); + + const block = useMemo( + () => createNodeBlock(node?.type ? prop(node?.type, NODE_COMPONENTS) : undefined), + [node, createNodeBlock] + ); + + const inline = useMemo( + () => createNodeBlock(node?.type ? prop(node?.type, NODE_INLINES) : undefined), + [node, createNodeBlock] + ); + + return { head, block, inline }; +}; diff --git a/src/utils/hooks/node/useNodeCoverImage.ts b/src/utils/hooks/node/useNodeCoverImage.ts new file mode 100644 index 00000000..5f4e7b39 --- /dev/null +++ b/src/utils/hooks/node/useNodeCoverImage.ts @@ -0,0 +1,16 @@ +import { INode } from '~/redux/types'; +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { nodeSetCoverImage } from '~/redux/node/actions'; + +export const useNodeCoverImage = (node: INode) => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(nodeSetCoverImage(node.cover)); + + return () => { + nodeSetCoverImage(undefined); + }; + }, [dispatch, node.cover, node.id]); +}; diff --git a/src/utils/hooks/node/useNodeImages.ts b/src/utils/hooks/node/useNodeImages.ts new file mode 100644 index 00000000..4f6b71d5 --- /dev/null +++ b/src/utils/hooks/node/useNodeImages.ts @@ -0,0 +1,9 @@ +import { INode } from '~/redux/types'; +import { useMemo } from 'react'; +import { UPLOAD_TYPES } from '~/redux/uploads/constants'; + +export const useNodeImages = (node: INode) => { + return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [ + node.files, + ]); +}; diff --git a/src/utils/hooks/node/useNodePermissions.ts b/src/utils/hooks/node/useNodePermissions.ts new file mode 100644 index 00000000..4f93ba78 --- /dev/null +++ b/src/utils/hooks/node/useNodePermissions.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; +import { canEditNode, canLikeNode, canStarNode } from '~/utils/node'; +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { selectUser } from '~/redux/auth/selectors'; +import { INode } from '~/redux/types'; + +export const useNodePermissions = (node: INode) => { + const user = useShallowSelect(selectUser); + const edit = useMemo(() => canEditNode(node, user), [node, user]); + const like = useMemo(() => canLikeNode(node, user), [node, user]); + const star = useMemo(() => canStarNode(node, user), [node, user]); + + return [edit, like, star]; +}; 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/hooks/useScrollToTop.ts b/src/utils/hooks/useScrollToTop.ts new file mode 100644 index 00000000..e1e03cc4 --- /dev/null +++ b/src/utils/hooks/useScrollToTop.ts @@ -0,0 +1,7 @@ +import { useEffect } from 'react'; + +export const useScrollToTop = (deps?: any[]) => { + useEffect(() => { + window.scrollTo(0, 0); + }, deps || []); +}; diff --git a/src/utils/hooks/user/userUser.ts b/src/utils/hooks/user/userUser.ts new file mode 100644 index 00000000..018b5fde --- /dev/null +++ b/src/utils/hooks/user/userUser.ts @@ -0,0 +1,4 @@ +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { selectUser } from '~/redux/auth/selectors'; + +export const useUser = () => useShallowSelect(selectUser); diff --git a/src/utils/node.ts b/src/utils/node.ts index d8254eda..f00d006c 100644 --- a/src/utils/node.ts +++ b/src/utils/node.ts @@ -1,10 +1,8 @@ import { USER_ROLES } from '~/redux/auth/constants'; -import { ICommentGroup, IFile, INode } from '~/redux/types'; +import { ICommentGroup, INode } from '~/redux/types'; import { IUser } from '~/redux/auth/types'; import { path } from 'ramda'; import { NODE_TYPES } from '~/redux/node/constants'; -import { useMemo } from 'react'; -import { UPLOAD_TYPES } from '~/redux/uploads/constants'; export const canEditNode = (node: Partial, user: Partial): boolean => path(['role'], user) === USER_ROLES.ADMIN || @@ -21,11 +19,3 @@ export const canStarNode = (node: Partial, user: Partial): boolean node.type === NODE_TYPES.IMAGE && path(['role'], user) && path(['role'], user) === USER_ROLES.ADMIN; - -export const useNodeImages = (node: INode): IFile[] => { - return useMemo( - () => - (node && node.files && node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || [], - [node.files] - ); -}; 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 => { + 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 67104881..a3992a4e 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" @@ -1761,6 +1768,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/swiper@^5.4.2": + version "5.4.2" + resolved "https://registry.yarnpkg.com/@types/swiper/-/swiper-5.4.2.tgz#ff206cf5aea787f580b5dd9b466b4bcb8e0442f3" + integrity sha512-/7MaVDZ8ltMCZb6yfg1HWBRjwFjy9ytKpuPSZfNTrxpkQCaGQZdpceDSqKaSfGmJcVF0NcBFRsGTStyytV7grw== + "@types/testing-library__jest-dom@^5.9.1": version "5.9.5" resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0" @@ -2632,10 +2644,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" @@ -2711,7 +2723,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= @@ -4084,6 +4096,13 @@ dom-serializer@0: domelementtype "^2.0.1" entities "^2.0.0" +dom7@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/dom7/-/dom7-3.0.0.tgz#b861ce5d67a6becd7aaa3ad02942ff14b1240331" + integrity sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g== + dependencies: + ssr-window "^3.0.0-alpha.1" + domain-browser@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" @@ -4188,17 +4207,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" @@ -5457,7 +5476,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= @@ -5772,9 +5791,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== inquirer@7.0.4: version "7.0.4" @@ -7354,7 +7373,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= @@ -9442,6 +9461,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" @@ -9768,6 +9795,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" @@ -10449,6 +10481,11 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +ssr-window@^3.0.0, ssr-window@^3.0.0-alpha.1: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ssr-window/-/ssr-window-3.0.0.tgz#fd5b82801638943e0cc704c4691801435af7ac37" + integrity sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA== + ssri@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" @@ -10789,6 +10826,14 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" +swiper@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/swiper/-/swiper-6.5.0.tgz#4ca2243b44fccef47ee28199377666607d8c5141" + integrity sha512-cSx1SpfgrHlgwku++3Ce3cjPBpXgB7P+bGik5S3+F+j6ID0NUeV6qtmedFdr3C8jXR/W+TJPVNIT9fH/cwVAiA== + dependencies: + dom7 "^3.0.0" + ssr-window "^3.0.0" + symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"