diff --git a/.gitignore b/.gitignore index 01590c86..ded927c8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /npm-debug.log /.idea /.env +/dist \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000..cff2ec24 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,49 @@ +pipeline { + agent any + + environment { + WWW = "${env.BRANCH_NAME == "master" ? env.VAULT_STABLE_WWW : env.VAULT_STAGING_WWW}" + ENV = "${env.BRANCH_NAME == "master" ? env.VAULT_STABLE_ENV : env.VAULT_STAGING_ENV}" + } + + stages { + stage('check') { + steps { + script { + if("${WWW}" == "" || "${ENV}" == "") { + currentBuild.result = 'FAILED' + error "No valid deploy dirs" + return + } + } + } + } + + stage('copy env') { + steps { + sh "cp -a ${ENV}/. ${WORKSPACE}" + } + } + + stage('build') { + steps { + sh 'yarn' + sh "mkdir -p ${WORKSPACE}/src/stats" + sh "git log -n 50 --pretty=format:\' { \"commit\": \"%H\", \"subject\": \"%s\", \"timestamp\": \"%at\" }\' | awk \'BEGIN { print(\"[\") } { print(\$0\",\") } END { print(\" {}\\n]\") }\' > ${WORKSPACE}/src/stats/git.json" + sh 'yarn build' + } + } + + stage('deploy') { + when { + anyOf { branch 'master'; branch 'develop' } + } + + steps { + sh "rm -rf ${WWW}" + sh "mv ${WORKSPACE}/dist ${WWW}" + } + } + + } +} \ No newline at end of file diff --git a/custom.d.ts b/custom.d.ts index ef4c425f..15756614 100644 --- a/custom.d.ts +++ b/custom.d.ts @@ -1,14 +1,19 @@ -declare module "*.svg" { +declare module '*.svg' { const content: any; export default content; } declare module '*.scss' { - const content: {[className: string]: string}; + const content: { [className: string]: string }; export = content; } declare module '*.less' { - const content: {[className: string]: string}; + const content: { [className: string]: string }; export = content; } + +declare module '*.json' { + const content: any; + export default content; +} diff --git a/package.json b/package.json index 3b6898b5..1eb3a54a 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,13 @@ "url": "https://github.com/muerwre/my-empty-react-project" }, "devDependencies": { - "@types/classnames": "^2.2.7", - "@types/node": "^11.13.22", - "@types/ramda": "^0.26.33", - "@types/react": "16.9.23", "@babel/cli": "^7.6.4", "@babel/preset-env": "^7.6.3", "@babel/types": "7.5.5", + "@types/classnames": "^2.2.7", + "@types/node": "^11.13.22", + "@types/ramda": "^0.26.33", + "@types/react": "16.9.11", "@types/react-router": "^5.1.2", "autoresponsive-react": "^1.1.31", "awesome-typescript-loader": "^5.2.1", @@ -85,6 +85,7 @@ "less-middleware": "~2.2.1", "lodash": "^4.17.10", "node-sass": "^4.11.0", + "photoswipe": "^4.1.3", "raleway-cyrillic": "^4.0.2", "ramda": "^0.26.1", "react": "16.13.0", @@ -94,21 +95,32 @@ "react-redux": "^6.0.1", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", - "react-sortable-hoc": "^1.10.1", + "react-sortable-hoc": "^1.11", "redux": "^4.0.1", "redux-persist": "^5.10.0", "redux-saga": "^1.1.1", "reduxsauce": "^1.0.0", + "resize-sensor": "^0.0.6", "sass-loader": "^7.3.1", "sass-resources-loader": "^2.0.0", "scrypt": "^6.0.3", "sticky-sidebar": "^3.3.1", "throttle-debounce": "^2.1.0", + "tinycolor": "^0.0.1", "tslint": "^5.20.0", "tslint-config-airbnb": "^5.11.2", "tslint-react": "^4.1.0", "tslint-react-hooks": "^2.2.1", "tt-react-custom-scrollbars": "latest", "uuid4": "^1.1.4" + }, + "resolutions": { + "**/**/minimist": "^1.2.3", + "**/**/acorn": "^6.4.1", + "**/**/kind-of": "^6.0.3", + "**/**/serialize-javascript": "^2.1.1", + "**/**/js-yaml": "^3.13.1", + "**/**/cryptiles": "^4.1.2", + "**/**/hoek": "^4.2.1" } -} \ No newline at end of file +} diff --git a/src/components/boris/BorisStats/index.tsx b/src/components/boris/BorisStats/index.tsx new file mode 100644 index 00000000..97f2e975 --- /dev/null +++ b/src/components/boris/BorisStats/index.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import { IBorisState } from '~/redux/boris/reducer'; +import { BorisStatsGit } from '../BorisStatsGit'; +import { BorisStatsBackend } from '../BorisStatsBackend'; + +interface IProps { + stats: IBorisState['stats']; +} + +const BorisStats: FC = ({ stats }) => { + return ( + <> + + + + ); +}; + +export { BorisStats }; diff --git a/src/components/boris/BorisStatsBackend/index.tsx b/src/components/boris/BorisStatsBackend/index.tsx new file mode 100644 index 00000000..d4ca7916 --- /dev/null +++ b/src/components/boris/BorisStatsBackend/index.tsx @@ -0,0 +1,74 @@ +import React, { FC } from 'react'; +import { IBorisState } from '~/redux/boris/reducer'; +import styles from './styles.module.scss'; +import { Placeholder } from '~/components/placeholders/Placeholder'; +import { sizeOf } from '~/utils/dom'; + +interface IProps { + stats: IBorisState['stats']; +} + +const BorisStatsBackend: FC = ({ stats }) => { + if (stats.is_loading) + return ( + <> +
+ +
+ + ); + + if (!stats.backend) return null; + + return ( +
+
Юнитс
+ +
    +
  • + В сознании {stats.backend.users.alive} +
  • + +
  • + Криокамера {stats.backend.users.total - stats.backend.users.alive} +
  • +
+ +
Контент
+ +
    +
  • + Фотографии {stats.backend.nodes.images} +
  • + +
  • + Письма {stats.backend.nodes.texts} +
  • + +
  • + Видеозаписи {stats.backend.nodes.videos} +
  • + +
  • + Аудиозаписи {stats.backend.nodes.audios} +
  • + +
  • + Комментарии {stats.backend.comments.total} +
  • +
+ +
Сторедж
+
    +
  • + Файлы {stats.backend.files.count} +
  • +
  • + На диске {sizeOf(stats.backend.files.size)} +
  • +
+
+ ); +}; + +export { BorisStatsBackend }; diff --git a/src/components/boris/BorisStatsBackend/styles.module.scss b/src/components/boris/BorisStatsBackend/styles.module.scss new file mode 100644 index 00000000..1d3912e8 --- /dev/null +++ b/src/components/boris/BorisStatsBackend/styles.module.scss @@ -0,0 +1,36 @@ +.wrap { + ul { + font: $font_12_regular; + line-height: 24px; + text-transform: uppercase; + + span { + float: right; + color: white; + font: $font_12_semibold; + line-height: 24px; + } + + li { + border-bottom: 1px solid #333333; + color: #aaaaaa; + + &:last-child { + border-bottom: none; + } + } + } +} + +.title { + font: $font_12_semibold; + text-transform: uppercase; + opacity: 0.3; + margin: $gap * 2 0 $gap; +} + +.subtitle { + margin-left: $gap; + font: $font_12_semibold; + text-transform: uppercase; +} diff --git a/src/components/boris/BorisStatsGit/index.tsx b/src/components/boris/BorisStatsGit/index.tsx new file mode 100644 index 00000000..78ac86e4 --- /dev/null +++ b/src/components/boris/BorisStatsGit/index.tsx @@ -0,0 +1,45 @@ +import React, { FC } from 'react'; +import { IBorisState } from '~/redux/boris/reducer'; +import styles from './styles.module.scss'; +import { Placeholder } from '~/components/placeholders/Placeholder'; +import { BorisStatsGitCard } from '../BorisStatsGitCard'; + +interface IProps { + stats: IBorisState['stats']; +} + +const BorisStatsGit: FC = ({ stats }) => { + if (!stats.git.length) return null; + + if (stats.is_loading) { + return ( + <> +
+ +
+ + + + + + + + + ); + } + + return ( +
+
КОММИТС
+ + {stats.git + .filter(data => data.commit && data.timestamp && data.subject) + .slice(0, 5) + .map(data => ( + + ))} +
+ ); +}; + +export { BorisStatsGit }; diff --git a/src/components/boris/BorisStatsGit/styles.module.scss b/src/components/boris/BorisStatsGit/styles.module.scss new file mode 100644 index 00000000..f46c72f4 --- /dev/null +++ b/src/components/boris/BorisStatsGit/styles.module.scss @@ -0,0 +1,8 @@ +.stats { + &__title { + font: $font_12_semibold; + text-transform: uppercase; + opacity: 0.3; + margin: $gap * 2 0 $gap; + } +} diff --git a/src/components/boris/BorisStatsGitCard/index.tsx b/src/components/boris/BorisStatsGitCard/index.tsx new file mode 100644 index 00000000..8d416ece --- /dev/null +++ b/src/components/boris/BorisStatsGitCard/index.tsx @@ -0,0 +1,24 @@ +import React, { FC } from 'react'; +import { IStatGitRow } from '~/redux/boris/reducer'; +import styles from './styles.module.scss'; +import { getPrettyDate } from '~/utils/dom'; + +interface IProps { + data: IStatGitRow; +} + +const BorisStatsGitCard: FC = ({ data: { timestamp, subject } }) => { + if (!subject || !timestamp) return null; + + return ( +
+
+ {getPrettyDate(new Date(parseInt(`${timestamp}000`)).toISOString())} +
+ +
{subject}
+
+ ); +}; + +export { BorisStatsGitCard }; diff --git a/src/components/boris/BorisStatsGitCard/styles.module.scss b/src/components/boris/BorisStatsGitCard/styles.module.scss new file mode 100644 index 00000000..0aac62af --- /dev/null +++ b/src/components/boris/BorisStatsGitCard/styles.module.scss @@ -0,0 +1,18 @@ +.wrap { + padding: $gap / 2 0; + border-bottom: 1px solid #333333; + + &:last-child { + border-bottom: none; + } +} + +.time { + font: $font_12_regular; + line-height: 17px; + opacity: 0.3; +} + +.subject { + font: $font_14_regular; +} diff --git a/src/components/comment/CommentEmbedBlock/index.tsx b/src/components/comment/CommentEmbedBlock/index.tsx index dd4e8e98..e973cd2e 100644 --- a/src/components/comment/CommentEmbedBlock/index.tsx +++ b/src/components/comment/CommentEmbedBlock/index.tsx @@ -1,31 +1,70 @@ -import React, { FC, memo, useMemo } from 'react'; -import { ICommentBlock } from '~/constants/comment'; +import React, { FC, memo, useMemo, useEffect } from 'react'; +import { ICommentBlockProps } from '~/constants/comment'; import styles from './styles.scss'; -import { getYoutubeTitle, getYoutubeThumb } from '~/utils/dom'; +import { getYoutubeThumb } from '~/utils/dom'; +import { selectPlayer } from '~/redux/player/selectors'; +import { connect } from 'react-redux'; +import * as PLAYER_ACTIONS from '~/redux/player/actions'; import { Icon } from '~/components/input/Icon'; -interface IProps { - block: ICommentBlock; -} - -const CommentEmbedBlock: FC = memo(({ block }) => { - const link = block.content.match( - /(https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?[\w\-\&\=]+)/gi - ); - - const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]); - - return ( - - ); +const mapStateToProps = state => ({ + youtubes: selectPlayer(state).youtubes, }); +const mapDispatchToProps = { + playerGetYoutubeInfo: PLAYER_ACTIONS.playerGetYoutubeInfo, +}; + +type Props = ReturnType & + typeof mapDispatchToProps & + ICommentBlockProps & {}; + +const CommentEmbedBlockUnconnected: FC = memo( + ({ block, youtubes, playerGetYoutubeInfo }) => { + const link = useMemo( + () => + block.content.match( + /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?([\w\-\=]+)/ + ), + [block.content] + ); + + const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]); + + useEffect(() => { + if (!link[5] || youtubes[link[5]]) return; + playerGetYoutubeInfo(link[5]); + }, [link, playerGetYoutubeInfo]); + + const title = useMemo( + () => + (youtubes[link[5]] && youtubes[link[5]].metadata && youtubes[link[5]].metadata.title) || '', + [link, youtubes] + ); + + return ( + + ); + } +); + +const CommentEmbedBlock = connect( + mapStateToProps, + mapDispatchToProps +)(CommentEmbedBlockUnconnected); + export { CommentEmbedBlock }; diff --git a/src/components/comment/CommentEmbedBlock/styles.scss b/src/components/comment/CommentEmbedBlock/styles.scss index 2ff6b40e..9503311c 100644 --- a/src/components/comment/CommentEmbedBlock/styles.scss +++ b/src/components/comment/CommentEmbedBlock/styles.scss @@ -9,7 +9,15 @@ display: flex; align-items: center; justify-content: flex-start; - margin: 0 0 $gap 0; + margin: $gap / 4 0 !important; + + &:last-child { + margin-bottom: 0 !important; + } + + &:first-child { + margin-top: 0 !important; + } a { position: absolute; @@ -33,21 +41,22 @@ left: 0; width: 100%; height: 100%; - background: transparentize(black, 0.5); + background: transparentize($comment_bg, 0.15) 50% 50%; + background-size: cover; z-index: 15; border-radius: $radius; display: flex; align-items: center; justify-content: center; + text-align: center; + font: $font_16_medium; + flex-direction: row; - @include can_backdrop { - background: transparentize(black, 0.3); - backdrop-filter: blur(5px); - } + @include outer_shadow(); } .preview { - padding: 0 $gap $gap / 2; + padding: 0 $gap / 2 0; position: absolute; top: 0; left: 0; @@ -63,5 +72,27 @@ width: 100%; border-radius: $radius; position: relative; + background-position: 50% 50%; + background-size: cover; } } + +.play { + flex: 0 0 $comment_height - $gap; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.title { + flex: 1; + text-align: left; + box-sizing: border-box; + font: $font_18_semibold; + padding: 0 $gap 0 $gap * 1.5; + text-transform: capitalize; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/comment/CommentTextBlock/index.tsx b/src/components/comment/CommentTextBlock/index.tsx index a782d0e3..81714657 100644 --- a/src/components/comment/CommentTextBlock/index.tsx +++ b/src/components/comment/CommentTextBlock/index.tsx @@ -1,10 +1,8 @@ import React, { FC } from 'react'; -import { ICommentBlock } from '~/constants/comment'; +import { ICommentBlockProps } from '~/constants/comment'; import styles from './styles.scss'; -interface IProps { - block: ICommentBlock; -} +interface IProps extends ICommentBlockProps {} const CommentTextBlock: FC = ({ block }) => { return ( diff --git a/src/components/comment/CommentTextBlock/styles.scss b/src/components/comment/CommentTextBlock/styles.scss index 6a14f11c..caab6c62 100644 --- a/src/components/comment/CommentTextBlock/styles.scss +++ b/src/components/comment/CommentTextBlock/styles.scss @@ -7,8 +7,34 @@ position: relative; color: #cccccc; word-break: break-word; + width: 100%; b { - font-weight: 600; + font-weight: 900; + } + + i { + font-style: italic; + } + + hr { + height: 1px; + background: rgba(255, 255, 255, 0.1); + border: none; + } + + :global(.green) { + color: $wisegreen; + } + + &:last-child { + p { + &::after { + content: ''; + display: inline-flex; + height: 1em; + width: 150px; + } + } } } diff --git a/src/components/containers/BlurWrapper/styles.scss b/src/components/containers/BlurWrapper/styles.scss index bcd83f5e..fd537904 100644 --- a/src/components/containers/BlurWrapper/styles.scss +++ b/src/components/containers/BlurWrapper/styles.scss @@ -1,7 +1,4 @@ .blur { - filter: blur(0); - transition: filter 0.25s; - will-change: filter; padding-top: $header_height + 2px; display: flex; box-sizing: border-box; diff --git a/src/components/containers/PageCover/styles.scss b/src/components/containers/PageCover/styles.scss index 4c2b9724..92f9c352 100644 --- a/src/components/containers/PageCover/styles.scss +++ b/src/components/containers/PageCover/styles.scss @@ -18,6 +18,7 @@ height: 100%; animation: fadeIn 2s; will-change: transform, opacity; + filter: blur(10px); &::after { content: ' '; @@ -26,7 +27,7 @@ left: 0; width: 100%; height: 100%; - background: url(~/sprites/stripes.svg) rgba(0, 0, 0, 0.3); + background: url(~/sprites/stripes.svg) transparentize($content_bg, 0.2); } @include tablet { diff --git a/src/components/containers/Sticky/index.tsx b/src/components/containers/Sticky/index.tsx new file mode 100644 index 00000000..e70d6727 --- /dev/null +++ b/src/components/containers/Sticky/index.tsx @@ -0,0 +1,41 @@ +import React, { FC, ReactComponentElement, DetailsHTMLAttributes, useEffect, useRef } from 'react'; +import styles from './styles.scss'; +import StickySidebar from 'sticky-sidebar'; +import classnames from 'classnames'; +import ResizeSensor from 'resize-sensor'; + +interface IProps extends DetailsHTMLAttributes {} + +(window as any).StickySidebar = StickySidebar; +(window as any).ResizeSensor = ResizeSensor; + +const Sticky: FC = ({ children }) => { + const ref = useRef(null); + let sb = null; + + useEffect(() => { + if (!ref.current) return; + + sb = new StickySidebar(ref.current, { + resizeSensor: true, + topSpacing: 72, + bottomSpacing: 10, + }); + + return () => sb.destroy(); + }, [ref.current, children]); + + if (sb) { + sb.updateSticky(); + } + + return ( +
+
+
{children}
+
+
+ ); +}; + +export { Sticky }; diff --git a/src/components/containers/Sticky/styles.scss b/src/components/containers/Sticky/styles.scss new file mode 100644 index 00000000..8bca8106 --- /dev/null +++ b/src/components/containers/Sticky/styles.scss @@ -0,0 +1,15 @@ +.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/flow/Cell/index.tsx b/src/components/flow/Cell/index.tsx index c687156f..b52d0b16 100644 --- a/src/components/flow/Cell/index.tsx +++ b/src/components/flow/Cell/index.tsx @@ -10,6 +10,7 @@ import { PRESETS } from '~/constants/urls'; import { debounce } from 'throttle-debounce'; import { NODE_TYPES } from '~/redux/node/constants'; import { Group } from '~/components/containers/Group'; +import { Link } from 'react-router-dom'; const THUMBNAIL_SIZES = { horizontal: PRESETS.small_hero, @@ -67,6 +68,7 @@ const Cell: FC = ({ setIsLoaded(true); }, [setIsLoaded]); + // Replaced it with , maybe, you can remove it completely with NodeSelect action const onClick = useCallback(() => onSelect(id, type), [onSelect, id, type]); const has_description = description && description.length > 32; @@ -130,7 +132,7 @@ const Cell: FC = ({ )} -
+
{title && !text &&
{title}
} @@ -150,7 +152,7 @@ const Cell: FC = ({
)}
- + {thumbnail && (
img { @@ -180,82 +191,9 @@ z-index: 2; border-radius: $cell_radius; padding: $gap / 2; - // pointer-events: none; - // touch-action: none; animation: appear 1s forwards; - - // @media (min-width: $cell * 2 + $grid_line) { - // .vertical > &.has_text, - // .horizontal > &.has_text, - // .quadro > &.has_text { - // box-sizing: border-box; - // background: none; - // box-shadow: none; - // padding: $grid_line; - - // &::after { - // display: none; - // } - - // .face_content { - // padding: $gap; - // background: rgba(25, 25, 25, 0.9); - // border-radius: $radius; - // overflow: hidden; - // position: relative; - - // &:after { - // content: ""; - // background: linear-gradient( - // transparentize($content_bg, 1), - // $content_bg - // ); - // position: absolute; - // bottom: 0; - // left: 0; - // height: 50px; - // width: 100%; - // border-radius: 0 0 $cell_radius $cell_radius; - // } - // } - - // .text::after { - // display: none; - // } - // } - - // .vertical > &.has_text { - // top: auto; - // bottom: 0; - // max-height: 50%; - // max-width: 100%; - // height: auto; - // width: auto; - // padding: ($grid_line / 2) $grid_line $grid_line $grid_line; - // } - - // .horizontal > &.has_text { - // top: auto; - // left: 0; - // height: 100%; - // max-width: 50%; - // // height: auto; - // width: auto; - // bottom: 0; - // padding: $grid_line ($grid_line / 2) $grid_line $grid_line; - // } - - // .quadro > &.has_text { - // padding: ($grid_line / 2) ($grid_line / 2) $grid_line $grid_line; - // top: auto; - // max-height: 50%; - // max-width: 50%; - // height: auto; - // width: auto; - // bottom: 0; - // left: 0; - // } - // } + color: white; + text-decoration: none; } .menu { diff --git a/src/components/flow/FlowGrid/index.tsx b/src/components/flow/FlowGrid/index.tsx index 064a92d3..f0e58c33 100644 --- a/src/components/flow/FlowGrid/index.tsx +++ b/src/components/flow/FlowGrid/index.tsx @@ -1,14 +1,11 @@ import React, { FC } from 'react'; import { Cell } from '~/components/flow/Cell'; -import * as styles from './styles.scss'; import { IFlowState } from '~/redux/flow/reducer'; import { INode } from '~/redux/types'; import { canEditNode } from '~/utils/node'; import { IUser } from '~/redux/auth/types'; import { flowSetCellView } from '~/redux/flow/actions'; -import { FlowHero } from '../FlowHero'; -import { FlowRecent } from '../FlowRecent'; type IProps = Partial & { user: Partial; @@ -16,33 +13,16 @@ type IProps = Partial & { onChangeCellView: typeof flowSetCellView; }; -export const FlowGrid: FC = ({ - user, - nodes, - heroes, - recent, - updated, - onSelect, - onChangeCellView, -}) => ( -
-
-
- -
-
- -
- - {nodes.map(node => ( - - ))} -
-
+export const FlowGrid: FC = ({ user, nodes, onSelect, onChangeCellView }) => ( + <> + {nodes.map(node => ( + + ))} + ); diff --git a/src/components/flow/FlowGrid/styles.scss b/src/components/flow/FlowGrid/styles.scss index 2156f950..e69de29b 100644 --- a/src/components/flow/FlowGrid/styles.scss +++ b/src/components/flow/FlowGrid/styles.scss @@ -1,106 +0,0 @@ -$cols: $content_width / $cell; -$stamp_color: $content_bg; - -.grid { - padding: $gap / 2; - margin: 0 (-$gap / 2); -} - -.grid_test { - display: grid; - - grid-template-columns: repeat(5, 1fr); - grid-template-rows: 50vh $cell; - grid-auto-rows: $cell; - - grid-auto-flow: row dense; - grid-column-gap: $gap; - grid-row-gap: $gap; - - @include tablet { - padding: 0 $gap; - } - - @media (max-width: $cell * 6) { - grid-template-columns: repeat(5, 1fr); - grid-template-rows: 50vh 20vw; - grid-auto-rows: 20vw; - } - - @media (max-width: $cell * 5) { - grid-template-columns: repeat(4, 1fr); - grid-template-rows: 40vh 25vw; - grid-auto-rows: 25vw; - } - - @media (max-width: $cell * 4) { - grid-template-columns: repeat(3, 1fr); - grid-template-rows: 40vh 33vw; - grid-auto-rows: 33vw; - } - - @media (max-width: $cell * 3) { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: 40vh 50vw; - grid-auto-rows: 50vw; - } - - @media (max-width: $cell * 2) { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: 40vh 50vw; - grid-auto-rows: 50vw; - grid-column-gap: $gap; - grid-row-gap: $gap; - } -} - -.pad_last { - grid-column-end: $cols + 1; -} - -.hero { - grid-row-start: 1; - grid-row-end: span 1; - grid-column-start: 1; - grid-column-end: -1; - background: darken($content_bg, 2%); - border-radius: $radius; - display: flex; - align-items: center; - justify-content: center; - font: $font_24_semibold; -} - -.stamp { - @include outer_shadow(); - // grid-row: -1 / 3; - grid-row-end: span 2; - grid-column: -2 / -1; - background: $stamp_color; - border-radius: $radius; - padding: $gap; - display: flex; - align-items: center; - justify-content: center; - font: $font_24_semibold; - display: flex; - align-items: stretch; - justify-content: stretch; - overflow: hidden; - position: relative; - - &::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - height: 60px; - width: 100%; - background: linear-gradient( - transparentize($stamp_color, 1), - $stamp_color 90% - ); - pointer-events: none; - touch-action: none; - } -} diff --git a/src/components/flow/FlowHero/index.tsx b/src/components/flow/FlowHero/index.tsx index b2ba43a3..2f0c36b8 100644 --- a/src/components/flow/FlowHero/index.tsx +++ b/src/components/flow/FlowHero/index.tsx @@ -16,10 +16,9 @@ const FlowHeroUnconnected: FC = ({ heroes, history }) => { const [limit, setLimit] = useState(Math.min(heroes.length, 6)); const [current, setCurrent] = useState(0); const [loaded, setLoaded] = useState([]); + const timer = useRef(null); - const onLoad = useCallback(id => () => setLoaded([...loaded, id]), [setLoaded, loaded]); - const onNext = useCallback(() => { clearTimeout(timer.current); @@ -47,9 +46,7 @@ const FlowHeroUnconnected: FC = ({ heroes, history }) => { useEffect(() => { timer.current = setTimeout(onNext, 5000); - - return () => clearTimeout(timer.current); - }, [current]); + }, [current, onNext]); useEffect(() => { if (current === 0 && loaded.length > 0) setCurrent(loaded[0]); @@ -80,6 +77,8 @@ const FlowHeroUnconnected: FC = ({ heroes, history }) => { return item.title; }, [loaded, current, heroes]); + const preset = useMemo(() => (window.innerWidth <= 768 ? PRESETS.cover : PRESETS.small_hero), []); + return (
{loaded && loaded.length > 0 && ( @@ -104,13 +103,13 @@ const FlowHeroUnconnected: FC = ({ heroes, history }) => { [styles.is_active]: current === hero.id, })} style={{ - backgroundImage: `url("${getURL({ url: hero.thumbnail }, PRESETS.small_hero)}")`, + backgroundImage: `url("${getURL({ url: hero.thumbnail }, preset)}")`, }} key={hero.id} onClick={onClick} > {hero.thumbnail} diff --git a/src/components/flow/FlowHero/styles.scss b/src/components/flow/FlowHero/styles.scss index 84f4776c..f7f9865b 100644 --- a/src/components/flow/FlowHero/styles.scss +++ b/src/components/flow/FlowHero/styles.scss @@ -1,12 +1,3 @@ -// @keyframes rise { -// 0% { -// transform: translate(0, 0); -// } -// 100% { -// transform: translate(0, -10%); -// } -// } - .wrap { width: 100%; height: 100%; @@ -16,20 +7,22 @@ overflow: hidden; &::after { - content: " "; + content: ' '; position: absolute; top: 0; left: 0; width: 100%; height: 100%; - background: url("~/sprites/stripes.svg") rgba(0, 0, 0, 0.3); + background: url('~/sprites/stripes.svg') rgba(0, 0, 0, 0.3); z-index: 4; pointer-events: none; + box-shadow: inset transparentize($color: white, $amount: 0.85) 0 1px; touch-action: none; + border-radius: $radius; } &::before { - content: " "; + content: ' '; position: absolute; top: 0; left: 0; @@ -94,6 +87,7 @@ box-sizing: border-box; z-index: 5; flex-direction: row; + align-items: flex-end; } .title_wrap { @@ -105,19 +99,28 @@ font: $font_hero_title; text-transform: uppercase; text-overflow: ellipsis; + line-height: 1.2em; + + @include tablet { + white-space: initial; + word-wrap: break-word; + font: $font_32_bold; + max-height: 3.6em; + } } -.title { - flex: 0; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - padding: 0 $gap 0 0; - border-radius: $radius; - font: $font_hero_title; - text-transform: uppercase; -} +// .title { +// flex: 0; +// height: 48px; +// display: flex; +// align-items: center; +// justify-content: center; +// padding: 0 $gap 0 0; +// background: red; +// border-radius: $radius; +// font: $font_hero_title; +// text-transform: uppercase; +// } .buttons { display: flex; diff --git a/src/components/flow/FlowRecent/index.tsx b/src/components/flow/FlowRecent/index.tsx index e569d8fb..79bae2d4 100644 --- a/src/components/flow/FlowRecent/index.tsx +++ b/src/components/flow/FlowRecent/index.tsx @@ -1,48 +1,20 @@ import React, { FC } from 'react'; -import * as styles from './styles.scss'; import { IFlowState } from '~/redux/flow/reducer'; -import { getURL, getPrettyDate } from '~/utils/dom'; -import { Link } from 'react-router-dom'; -import { URLS, PRESETS } from '~/constants/urls'; -import classNames from 'classnames'; -import { NodeRelatedItem } from '~/components/node/NodeRelatedItem'; +import { FlowRecentItem } from '../FlowRecentItem'; interface IProps { recent: IFlowState['recent']; updated: IFlowState['updated']; } -const FlowRecent: FC = ({ recent, updated }) => ( -
- {updated && - updated.slice(0, 20).map(node => ( - -
+const FlowRecent: FC = ({ recent, updated }) => { + return ( + <> + {updated && updated.map(node => )} -
-
{node.title}
-
{getPrettyDate(node.created_at)}
-
- - ))} - - {recent && - recent.slice(0, 20).map(node => ( - -
- -
- -
-
{node.title}
-
{getPrettyDate(node.created_at)}
-
- - ))} -
-); + {recent && recent.map(node => )} + + ); +}; export { FlowRecent }; diff --git a/src/components/flow/FlowRecent/styles.scss b/src/components/flow/FlowRecent/styles.scss index f50d7fe8..e69de29b 100644 --- a/src/components/flow/FlowRecent/styles.scss +++ b/src/components/flow/FlowRecent/styles.scss @@ -1,69 +0,0 @@ -.grid { - display: flex; - justify-content: stretch; - flex-direction: column; - flex: 1; - - & > div { - // background: $content_bg; - // @include outer_shadow(); - // background: darken($content_bg, 4%); - } -} - -.item { - display: flex; - align-items: center; - justify-content: center; - font: $font_12_regular; - border-radius: $radius; - flex-direction: row; - margin-bottom: $gap; - color: white; - text-decoration: none; -} - -.thumb { - height: 48px; - margin-right: $gap; - background: 50% 50% no-repeat; - background-size: cover; - border-radius: $radius; - flex: 0 0 48px; - display: flex; - align-items: center; - justify-content: center; - position: relative; - - &.new { - &::after { - content: ' '; - width: 12px; - height: 12px; - border-radius: 100%; - background: $red; - box-shadow: $content_bg 0 0 0 5px; - position: absolute; - right: -2px; - bottom: -2px; - } - } -} - -.info { - flex: 1; - min-width: 0; -} - -.title { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - font: $font_16_semibold; -} - -.comment { - font: $font_12_regular; - margin-top: 4px; - opacity: 0.5; -} diff --git a/src/components/flow/FlowRecentItem/index.tsx b/src/components/flow/FlowRecentItem/index.tsx new file mode 100644 index 00000000..f4a17ef1 --- /dev/null +++ b/src/components/flow/FlowRecentItem/index.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; +import { INode } from '~/redux/types'; +import styles from './styles.scss'; +import { URLS } from '~/constants/urls'; +import { NodeRelatedItem } from '~/components/node/NodeRelatedItem'; +import { getPrettyDate } from '~/utils/dom'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; + +interface IProps { + node: Partial; + has_new?: boolean; +} + +const FlowRecentItem: FC = ({ node, has_new }) => { + return ( + +
+ +
+ +
+
{node.title}
+
{getPrettyDate(node.created_at)}
+
+ + ); +}; + +export { FlowRecentItem }; diff --git a/src/components/flow/FlowRecentItem/styles.scss b/src/components/flow/FlowRecentItem/styles.scss new file mode 100644 index 00000000..d103d429 --- /dev/null +++ b/src/components/flow/FlowRecentItem/styles.scss @@ -0,0 +1,57 @@ +.item { + display: flex; + align-items: center; + justify-content: center; + font: $font_12_regular; + border-radius: $radius; + flex-direction: row; + margin-bottom: $gap; + color: white; + text-decoration: none; +} + +.thumb { + height: 48px; + margin-right: $gap; + background: 50% 50% no-repeat; + background-size: cover; + border-radius: $radius; + flex: 0 0 48px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + &.new { + &::after { + content: ' '; + width: 12px; + height: 12px; + border-radius: 100%; + background: $red; + box-shadow: $content_bg 0 0 0 5px; + position: absolute; + right: -2px; + bottom: -2px; + } + } +} + +.info { + flex: 1; + min-width: 0; +} + +.title { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + font: $font_16_semibold; + text-transform: capitalize; +} + +.comment { + font: $font_12_regular; + margin-top: 4px; + opacity: 0.5; +} diff --git a/src/components/flow/FlowSearchResults/index.tsx b/src/components/flow/FlowSearchResults/index.tsx new file mode 100644 index 00000000..2b5e9925 --- /dev/null +++ b/src/components/flow/FlowSearchResults/index.tsx @@ -0,0 +1,52 @@ +import React, { FC, useCallback } from 'react'; +import styles from './styles.scss'; +import { IFlowState } from '~/redux/flow/reducer'; +import { LoaderCircle } from '~/components/input/LoaderCircle'; +import { FlowRecentItem } from '../FlowRecentItem'; +import { Icon } from '~/components/input/Icon'; + +interface IProps { + search: IFlowState['search']; + onLoadMore: () => void; +} + +const FlowSearchResults: FC = ({ search, onLoadMore }) => { + const onScroll = useCallback( + event => { + const el = event.target; + const bottom = el.scrollHeight - el.scrollTop - el.clientHeight; + + if (bottom > 100) return; + + onLoadMore(); + }, + [onLoadMore] + ); + + if (search.is_loading) { + return ( +
+ +
+ ); + } + + if (!search.results.length) { + return ( +
+ +
Ничего не найдено
+
+ ); + } + + return ( +
+ {search.results.map(node => ( + + ))} +
+ ); +}; + +export { FlowSearchResults }; diff --git a/src/components/flow/FlowSearchResults/styles.scss b/src/components/flow/FlowSearchResults/styles.scss new file mode 100644 index 00000000..7c88908e --- /dev/null +++ b/src/components/flow/FlowSearchResults/styles.scss @@ -0,0 +1,26 @@ +.wrap { + flex: 1; + overflow: auto; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + flex: 1; + opacity: 0.3; + fill: transparentize(white, 0.7); + stroke: none; +} + +.nothing { + color: white; + text-transform: uppercase; + font: $font_18_semibold; + font-weight: 900; + opacity: 0.3; + padding: 10px 20px; + text-align: center; + line-height: 1.5em; +} diff --git a/src/components/flow/FlowStamp/index.tsx b/src/components/flow/FlowStamp/index.tsx new file mode 100644 index 00000000..da5a6bd3 --- /dev/null +++ b/src/components/flow/FlowStamp/index.tsx @@ -0,0 +1,92 @@ +import React, { FC, useCallback, FormEvent, useMemo, KeyboardEvent } from 'react'; +import { IFlowState } from '~/redux/flow/reducer'; +import { InputText } from '~/components/input/InputText'; +import { FlowRecent } from '../FlowRecent'; +import classnames from 'classnames'; + +import * as styles from './styles.scss'; +import * as FLOW_ACTIONS from '~/redux/flow/actions'; +import { FlowSearchResults } from '../FlowSearchResults'; +import { Icon } from '~/components/input/Icon'; + +interface IProps { + recent: IFlowState['recent']; + updated: IFlowState['updated']; + search: IFlowState['search']; + flowChangeSearch: typeof FLOW_ACTIONS.flowChangeSearch; + onLoadMore: () => void; +} + +const FlowStamp: FC = ({ recent, updated, search, flowChangeSearch, onLoadMore }) => { + const onSearchChange = useCallback((text: string) => flowChangeSearch({ text }), [ + flowChangeSearch, + ]); + + const onSearchSubmit = useCallback((event: FormEvent) => { + event.preventDefault(); + }, []); + + const onClearSearch = useCallback(() => flowChangeSearch({ text: '' }), [flowChangeSearch]); + + const onKeyUp = useCallback( + event => { + if (event.key !== 'Escape') return; + onClearSearch(); + event.target.blur(); + }, + [onClearSearch] + ); + + const after = useMemo( + () => + search.text ? ( + + ) : ( + + ), + [search.text] + ); + + return ( +
+
+ + + +
+ {search.text ? ( + <> +
+ Результаты поиска + + {!search.is_loading && search.total} +
+ +
+ +
+ + ) : ( + <> +
+ Что нового? + +
+ +
+ +
+ + )} +
+
+ ); +}; + +export { FlowStamp }; diff --git a/src/components/flow/FlowStamp/styles.scss b/src/components/flow/FlowStamp/styles.scss new file mode 100644 index 00000000..0ca5216d --- /dev/null +++ b/src/components/flow/FlowStamp/styles.scss @@ -0,0 +1,99 @@ +.wrap { + display: flex; + flex-direction: column; + width: 100%; + background: lighten($content_bg, 4%); + border-radius: $radius; +} + +.grid { + display: flex; + justify-content: stretch; + flex-direction: column; + flex: 1; + border-radius: $radius; + overflow: hidden; + position: relative; + background: $content_bg; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + height: 60px; + width: 100%; + background: linear-gradient(transparentize($content_bg, 1), $content_bg 90%); + pointer-events: none; + touch-action: none; + } + + @include outer_shadow(); +} + +.items { + padding: 0 $gap 0 $gap; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.label { + display: flex; + flex-direction: row; + min-width: 0; + padding: $gap; + border-radius: $radius; + + @include title_with_line(); + + color: transparentize(white, $amount: 0.8); + + &_search { + color: white; + padding-left: $gap * 1.2; + } + + & > :global(.line) { + margin-right: $gap; + } +} + +.label_text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search { + background: lighten($content_bg, 3%); + border-radius: $radius $radius 0 0; + padding: $gap; + + @include outer_shadow(); + + :global(.input_title) { + color: lighten($content_bg, 10%); + } +} + +.search_icon { + fill: lighten($content_bg, 8%); + stroke: lighten($content_bg, 8%); + stroke-width: 0.5; + pointer-events: none; + touch-action: none; +} + +.close_icon { + cursor: pointer; + stroke: white; + stroke-width: 0.5; + opacity: 0.5; + transition: opacity 0.25s; + + &:hover { + opacity: 0.7; + } +} diff --git a/src/components/input/InputText/index.tsx b/src/components/input/InputText/index.tsx index 5428ff41..eae62ab5 100644 --- a/src/components/input/InputText/index.tsx +++ b/src/components/input/InputText/index.tsx @@ -16,6 +16,7 @@ const InputText: FC = ({ value = '', onRef, is_loading, + after, ...props }) => { const [focused, setFocused] = useState(false); @@ -61,6 +62,7 @@ const InputText: FC = ({
+
@@ -71,16 +73,20 @@ const InputText: FC = ({
+ {title && ( -
+
{title}
)} + {error && (
{error}
)} + + {!!after &&
{after}
}
); }; diff --git a/src/components/main/Footer/index.tsx b/src/components/main/Footer/index.tsx new file mode 100644 index 00000000..0ae17a29 --- /dev/null +++ b/src/components/main/Footer/index.tsx @@ -0,0 +1,13 @@ +import React, { FC, memo } from 'react'; +import styles from './styles.scss'; + +interface IProps {} + +const Footer: FC = memo(() => ( +
+
Уделяй больше времени тишине. Спасибо
+
2009 - {new Date().getFullYear()}
+
+)); + +export { Footer }; diff --git a/src/components/main/Footer/styles.scss b/src/components/main/Footer/styles.scss new file mode 100644 index 00000000..1fbf1779 --- /dev/null +++ b/src/components/main/Footer/styles.scss @@ -0,0 +1,23 @@ +.footer { + display: flex; + flex-direction: row; + font: $font_12_semibold; + background: transparentize(black, 0.9); + border-radius: 0 0 $radius $radius; + align-items: center; + text-transform: uppercase; + color: darken(white, 80%); + padding-top: 2px; + box-sizing: border-box; + + @include outer_shadow(); +} + +.slogan { + flex: 1; + padding: $gap; +} + +.copy { + padding: $gap; +} diff --git a/src/components/main/Header/index.tsx b/src/components/main/Header/index.tsx index 42d5170e..baec90ec 100644 --- a/src/components/main/Header/index.tsx +++ b/src/components/main/Header/index.tsx @@ -1,11 +1,11 @@ -import React, { FC, useCallback, memo, useState, useEffect } from 'react'; +import React, { FC, useCallback, memo, useState, useEffect, useMemo } from 'react'; import { connect } from 'react-redux'; import { push as historyPush } from 'connected-react-router'; import { Link } from 'react-router-dom'; import { Logo } from '~/components/main/Logo'; import { Filler } from '~/components/containers/Filler'; -import { selectUser } from '~/redux/auth/selectors'; +import { selectUser, selectAuthUpdates } from '~/redux/auth/selectors'; import { Group } from '~/components/containers/Group'; import { DIALOGS } from '~/redux/modal/constants'; import pick from 'ramda/es/pick'; @@ -19,9 +19,12 @@ import classNames from 'classnames'; import * as style from './style.scss'; 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'; -const mapStateToProps = state => ({ - user: pick(['username', 'is_user', 'photo'])(selectUser(state)), +const mapStateToProps = (state: IState) => ({ + user: pick(['username', 'is_user', 'photo', 'last_seen_boris'])(selectUser(state)), + updates: pick(['boris_commented_at'])(selectAuthUpdates(state)), pathname: path(['router', 'location', 'pathname'], state), }); @@ -35,7 +38,15 @@ const mapDispatchToProps = { type IProps = ReturnType & typeof mapDispatchToProps & {}; const HeaderUnconnected: FC = memo( - ({ user, user: { is_user }, showDialog, pathname, authLogout, authOpenProfile }) => { + ({ + user, + user: { is_user, last_seen_boris }, + showDialog, + pathname, + updates: { boris_commented_at }, + authLogout, + authOpenProfile, + }) => { const [is_scrolled, setIsScrolled] = useState(false); const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]); @@ -55,6 +66,14 @@ const HeaderUnconnected: FC = memo( return () => window.removeEventListener('scroll', onScroll); }, [onScroll]); + const hasBorisUpdates = useMemo( + () => + is_user && + boris_commented_at && + (!last_seen_boris || isBefore(new Date(last_seen_boris), new Date(boris_commented_at))), + [boris_commented_at, last_seen_boris] + ); + return createPortal(
@@ -71,7 +90,10 @@ const HeaderUnconnected: FC = memo( БОРИС diff --git a/src/components/main/Header/style.scss b/src/components/main/Header/style.scss index 4c0c8058..f3108962 100644 --- a/src/components/main/Header/style.scss +++ b/src/components/main/Header/style.scss @@ -90,11 +90,30 @@ transition: transform 0.5s, opacity 0.25s; } + &::after { + content: ' '; + position: absolute; + width: 6px; + height: 6px; + border-radius: 4px; + background: lighten($red, 10%); + right: 12px; + top: 6px; + transition: opacity 0.5s; + opacity: 0; + } + + &.has_dot { + &::after { + opacity: 1; + } + } + @include tablet { padding: $gap; &::after { - margin-left: $gap; + right: 0; } } } diff --git a/src/components/media/AudioPlayer/styles.scss b/src/components/media/AudioPlayer/styles.scss index ee5d160a..eab2ee3e 100644 --- a/src/components/media/AudioPlayer/styles.scss +++ b/src/components/media/AudioPlayer/styles.scss @@ -73,7 +73,7 @@ top: 0; text-align: left; transition: all 0.5s; - font: $font_16_medium; + font: $font_18_semibold; } .progress { diff --git a/src/components/node/Comment/index.tsx b/src/components/node/Comment/index.tsx index bf9c01dd..c126fa2b 100644 --- a/src/components/node/Comment/index.tsx +++ b/src/components/node/Comment/index.tsx @@ -7,6 +7,7 @@ import { nodeLockComment, nodeEditComment } from '~/redux/node/actions'; import { INodeState } from '~/redux/node/reducer'; import { CommentForm } from '../CommentForm'; import { CommendDeleted } from '../CommendDeleted'; +import * as MODAL_ACTIONS from '~/redux/modal/actions'; type IProps = HTMLAttributes & { is_empty?: boolean; @@ -17,6 +18,7 @@ type IProps = HTMLAttributes & { can_edit?: boolean; onDelete: typeof nodeLockComment; onEdit: typeof nodeEditComment; + modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; }; const Comment: FC = memo( @@ -30,6 +32,7 @@ const Comment: FC = memo( can_edit, onDelete, onEdit, + modalShowPhotoswipe, ...props }) => { return ( @@ -58,6 +61,7 @@ const Comment: FC = memo( can_edit={can_edit} onDelete={onDelete} onEdit={onEdit} + modalShowPhotoswipe={modalShowPhotoswipe} /> ); })} diff --git a/src/components/node/CommentContent/index.tsx b/src/components/node/CommentContent/index.tsx index 0f08b2a8..e559e1dc 100644 --- a/src/components/node/CommentContent/index.tsx +++ b/src/components/node/CommentContent/index.tsx @@ -12,99 +12,93 @@ import { AudioPlayer } from '~/components/media/AudioPlayer'; import classnames from 'classnames'; import { PRESETS } from '~/constants/urls'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; -import { Icon } from '~/components/input/Icon'; import { nodeLockComment, nodeEditComment } from '~/redux/node/actions'; import { CommentMenu } from '../CommentMenu'; +import * as MODAL_ACTIONS from '~/redux/modal/actions'; interface IProps { comment: IComment; can_edit: boolean; onDelete: typeof nodeLockComment; onEdit: typeof nodeEditComment; + modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; } -const CommentContent: FC = memo(({ comment, can_edit, onDelete, onEdit }) => { - const groupped = useMemo>( - () => - reduce( - (group, file) => assocPath([file.type], append(file, group[file.type]), group), - {}, - comment.files - ), - [comment] - ); +const CommentContent: FC = memo( + ({ comment, can_edit, onDelete, onEdit, modalShowPhotoswipe }) => { + const groupped = useMemo>( + () => + reduce( + (group, file) => assocPath([file.type], append(file, group[file.type]), group), + {}, + comment.files + ), + [comment] + ); - const onLockClick = useCallback(() => { - onDelete(comment.id, !comment.deleted_at); - }, [comment, onDelete]); + const onLockClick = useCallback(() => { + onDelete(comment.id, !comment.deleted_at); + }, [comment, onDelete]); - const onEditClick = useCallback(() => { - onEdit(comment.id); - }, [comment, onEdit]); + const onEditClick = useCallback(() => { + onEdit(comment.id); + }, [comment, onEdit]); - const menu = useMemo( - () => can_edit && , - [can_edit, comment, onEditClick, onLockClick] - ); + const menu = useMemo( + () => can_edit && , + [can_edit, comment, onEditClick, onLockClick] + ); - return ( -
- {comment.text && ( - - {menu} + return ( +
+ {comment.text && ( + + {menu} - {formatCommentText(path(['user', 'username'], comment), comment.text).map( - (block, key) => - COMMENT_BLOCK_RENDERERS[block.type] && - createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) - )} + + {formatCommentText(path(['user', 'username'], comment), comment.text).map( + (block, key) => + COMMENT_BLOCK_RENDERERS[block.type] && + createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) + )} + -
{getPrettyDate(comment.created_at)}
-
- )} +
{getPrettyDate(comment.created_at)}
+ + )} - {groupped.image && groupped.image.length > 0 && ( -
- {menu} + {groupped.image && groupped.image.length > 0 && ( +
+ {menu} -
- {groupped.image.map(file => ( -
- {file.name} +
+ {groupped.image.map((file, index) => ( +
modalShowPhotoswipe(groupped.image, index)}> + {file.name} +
+ ))} +
+ +
{getPrettyDate(comment.created_at)}
+
+ )} + + {groupped.audio && groupped.audio.length > 0 && ( + <> + {groupped.audio.map(file => ( +
+ {menu} + + + +
{getPrettyDate(comment.created_at)}
))} -
- -
{getPrettyDate(comment.created_at)}
-
- )} - - {groupped.audio && groupped.audio.length > 0 && ( - <> - {groupped.audio.map(file => ( -
- {menu} - - - -
{getPrettyDate(comment.created_at)}
-
- ))} - - )} -
- ); -}); + + )} +
+ ); + } +); export { CommentContent }; - -/* -{comment.text && ( - - )} - - - - - - */ diff --git a/src/components/node/CommentContent/styles.scss b/src/components/node/CommentContent/styles.scss index 15008ce3..e0806050 100644 --- a/src/components/node/CommentContent/styles.scss +++ b/src/components/node/CommentContent/styles.scss @@ -111,8 +111,13 @@ right: 0; font: $font_12_regular; color: transparentize($color: white, $amount: 0.8); - padding: 4px 6px 4px 6px; + padding: 0 6px 2px; border-radius: 0 0 $radius 0; + z-index: 2; + background: $comment_bg; + border-radius: 4px; + pointer-events: none; + touch-action: none; } .images { @@ -133,3 +138,8 @@ text-align: center; } } + +.renderers { + width: 100%; + margin: 0 !important; +} diff --git a/src/components/node/CommentForm/index.tsx b/src/components/node/CommentForm/index.tsx index 3a355a7c..4b9ce225 100644 --- a/src/components/node/CommentForm/index.tsx +++ b/src/components/node/CommentForm/index.tsx @@ -159,7 +159,11 @@ const CommentFormUnconnected: FC = memo( (fileId: IFile['id']) => { nodeSetCommentData( id, - assocPath(['files'], comment.files.filter(file => file.id != fileId), comment_data[id]) + assocPath( + ['files'], + comment.files.filter(file => file.id != fileId), + comment_data[id] + ) ); }, [comment_data, id, nodeSetCommentData] @@ -189,13 +193,17 @@ const CommentFormUnconnected: FC = memo( ['files'], [ ...audios, - ...(moveArrItem(oldIndex, newIndex, images.filter(file => !!file)) as IFile[]), + ...(moveArrItem( + oldIndex, + newIndex, + images.filter(file => !!file) + ) as IFile[]), ], comment_data[id] ) ); }, - [images, audios] + [images, audios, comment_data, nodeSetCommentData] ); const onAudioMove = useCallback( @@ -206,13 +214,17 @@ const CommentFormUnconnected: FC = memo( ['files'], [ ...images, - ...(moveArrItem(oldIndex, newIndex, audios.filter(file => !!file)) as IFile[]), + ...(moveArrItem( + oldIndex, + newIndex, + audios.filter(file => !!file) + ) as IFile[]), ], comment_data[id] ) ); }, - [images, audios] + [images, audios, comment_data, nodeSetCommentData] ); const onCancelEdit = useCallback(() => { @@ -299,9 +311,6 @@ const CommentFormUnconnected: FC = memo( } ); -const CommentForm = connect( - mapStateToProps, - mapDispatchToProps -)(CommentFormUnconnected); +const CommentForm = connect(mapStateToProps, mapDispatchToProps)(CommentFormUnconnected); export { CommentForm, CommentFormUnconnected }; diff --git a/src/components/node/ImageSwitcher/styles.scss b/src/components/node/ImageSwitcher/styles.scss index b54d77ba..4fb81734 100644 --- a/src/components/node/ImageSwitcher/styles.scss +++ b/src/components/node/ImageSwitcher/styles.scss @@ -2,23 +2,22 @@ width: 100%; height: 0; position: relative; - z-index: 2; + z-index: 4; } .switcher { position: absolute; - background: transparentize(black, 0.5); + // background: darken($content_bg, 2%); + background: url('../../../../src/sprites/noise.png') $main_bg_color; display: flex; - right: $gap; - top: $gap; + left: 50%; + transform: translate(-50%, 0); + top: -60px; border-radius: 24px; padding: 0 3px; - flex-wrap: wrap; + // flex-wrap: wrap; transition: background-color 0.5s; - - &:hover { - background: transparentize(black, 0.2); - } + transform: translate(-50%, 0); & > div { width: 30px; @@ -28,19 +27,14 @@ display: flex; align-items: center; justify-content: center; - opacity: 0.5; transition: opacity 0.25s; - - &:hover { - opacity: 1; - } + opacity: 0.5; &::after { content: ' '; display: block; width: 14px; height: 14px; - // background: white; border-radius: 8px; box-shadow: inset white 0 0 0 2px; transform: scale(0.5); diff --git a/src/components/node/NodeAudioBlock/index.tsx b/src/components/node/NodeAudioBlock/index.tsx index 7a4bde45..6b3595e9 100644 --- a/src/components/node/NodeAudioBlock/index.tsx +++ b/src/components/node/NodeAudioBlock/index.tsx @@ -3,10 +3,9 @@ import { INode } from '~/redux/types'; import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { AudioPlayer } from '~/components/media/AudioPlayer'; import * as styles from './styles.scss'; +import { INodeComponentProps } from '~/redux/node/constants'; -interface IProps { - node: INode; -} +interface IProps extends INodeComponentProps {} const NodeAudioBlock: FC = ({ node }) => { const audios = useMemo( diff --git a/src/components/node/NodeAudioImageBlock/index.tsx b/src/components/node/NodeAudioImageBlock/index.tsx index 95eb3fde..366bd861 100644 --- a/src/components/node/NodeAudioImageBlock/index.tsx +++ b/src/components/node/NodeAudioImageBlock/index.tsx @@ -5,10 +5,9 @@ import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import path from 'ramda/es/path'; import { getURL } from '~/utils/dom'; import { PRESETS } from '~/constants/urls'; +import { INodeComponentProps } from '~/redux/node/constants'; -interface IProps { - node: INode; -} +interface IProps extends INodeComponentProps {} const NodeAudioImageBlock: FC = ({ node }) => { const images = useMemo( diff --git a/src/components/node/NodeComments/index.tsx b/src/components/node/NodeComments/index.tsx index 5d37cd9d..c7f541da 100644 --- a/src/components/node/NodeComments/index.tsx +++ b/src/components/node/NodeComments/index.tsx @@ -7,36 +7,83 @@ import { ICommentGroup, IComment } from '~/redux/types'; import { groupCommentsByUser } from '~/utils/fn'; import { IUser } from '~/redux/auth/types'; import { canEditComment } from '~/utils/node'; -import { nodeLockComment, nodeEditComment } from '~/redux/node/actions'; +import { nodeLockComment, nodeEditComment, nodeLoadMoreComments } from '~/redux/node/actions'; import { INodeState } from '~/redux/node/reducer'; +import { COMMENTS_DISPLAY } from '~/redux/node/constants'; +import { plural } from '~/utils/dom'; +import * as MODAL_ACTIONS from '~/redux/modal/actions'; interface IProps { comments?: IComment[]; comment_data: INodeState['comment_data']; + comment_count: INodeState['comment_count']; user: IUser; onDelete: typeof nodeLockComment; onEdit: typeof nodeEditComment; + onLoadMore: typeof nodeLoadMoreComments; + order?: 'ASC' | 'DESC'; + modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; } -const NodeComments: FC = memo(({ comments, comment_data, user, onDelete, onEdit }) => { - const groupped: ICommentGroup[] = useMemo(() => comments.reduce(groupCommentsByUser, []), [ +const NodeComments: FC = memo( + ({ comments, - ]); + comment_data, + user, + onDelete, + onEdit, + onLoadMore, + comment_count = 0, + order = 'DESC', + modalShowPhotoswipe, + }) => { + const comments_left = useMemo(() => Math.max(0, comment_count - comments.length), [ + comments, + comment_count, + ]); - return ( -
- {groupped.map(group => ( - - ))} -
- ); -}); + const groupped: ICommentGroup[] = useMemo( + () => (order === 'DESC' ? [...comments].reverse() : comments).reduce(groupCommentsByUser, []), + [comments, order] + ); + + const more = useMemo( + () => + comments_left > 0 && ( +
+ Показать ещё{' '} + {plural( + Math.min(comments_left, COMMENTS_DISPLAY), + 'комментарий', + 'комментария', + 'комментариев' + )} + {comments_left > COMMENTS_DISPLAY ? ` из ${comments_left} оставшихся` : ''} +
+ ), + [comments_left, onLoadMore, COMMENTS_DISPLAY] + ); + + return ( +
+ {order === 'DESC' && more} + + {groupped.map(group => ( + + ))} + + {order === 'ASC' && more} +
+ ); + } +); export { NodeComments }; diff --git a/src/components/node/NodeComments/styles.scss b/src/components/node/NodeComments/styles.scss index 401c1465..e36f2422 100644 --- a/src/components/node/NodeComments/styles.scss +++ b/src/components/node/NodeComments/styles.scss @@ -7,3 +7,42 @@ } } } + +.more { + padding: $gap; + box-sizing: border-box; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + border-radius: $radius; + text-transform: uppercase; + color: darken(white, 60%); + font: $font_14_medium; + cursor: pointer; + transition: all 0.25s; + user-select: none; + background: url('~/sprites/stripes.svg'); + position: relative; + + &:hover { + color: $wisegreen; + background-color: darken($wisegreen, 12%); + + .bar { + background: $wisegreen; + } + } +} + +.bar { + position: absolute; + height: 2px; + border-radius: 2px; + background: darken(white, 60%); + bottom: 0; + left: 50%; + transform: translate(-50%, 0); + width: 50%; + transition: width 0.25s; +} diff --git a/src/components/node/NodeImageBlock/index.tsx b/src/components/node/NodeImageBlock/index.tsx index 0ccd33d1..124200d7 100644 --- a/src/components/node/NodeImageBlock/index.tsx +++ b/src/components/node/NodeImageBlock/index.tsx @@ -1,102 +1,99 @@ -import React, { - FC, - useMemo, - useState, - useEffect, - useRef, - useCallback -} from "react"; -import { ImageSwitcher } from "../ImageSwitcher"; -import * as styles from "./styles.scss"; -import { INode } from "~/redux/types"; -import classNames from "classnames"; -import { getURL } from "~/utils/dom"; -import { UPLOAD_TYPES } from "~/redux/uploads/constants"; -import { PRESETS } from "~/constants/urls"; +import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react'; +import { ImageSwitcher } from '../ImageSwitcher'; +import * as styles from './styles.scss'; +import { INode } from '~/redux/types'; +import classNames from 'classnames'; +import { getURL } from '~/utils/dom'; +import { UPLOAD_TYPES } from '~/redux/uploads/constants'; +import { PRESETS } from '~/constants/urls'; +import * as MODAL_ACTIONS from '~/redux/modal/actions'; +import { LoaderCircle } from '~/components/input/LoaderCircle'; interface IProps { is_loading: boolean; node: INode; layout: {}; updateLayout: () => void; + modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; } -const NodeImageBlock: FC = ({ node, is_loading, updateLayout }) => { +const NodeImageBlock: FC = ({ node, is_loading, updateLayout, modalShowPhotoswipe }) => { const [is_animated, setIsAnimated] = useState(false); const [current, setCurrent] = useState(0); - const [height, setHeight] = useState(320); + const [height, setHeight] = useState(window.innerHeight - 150); const [loaded, setLoaded] = useState>({}); const refs = useRef>({}); const images = useMemo( () => - (node && - node.files && - node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || - [], + (node && node.files && node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || [], [node] ); const setRef = useCallback(index => el => (refs.current[index] = el), [refs]); - const onImageLoad = useCallback( - index => () => setLoaded({ ...loaded, [index]: true }), - [setLoaded, loaded] - ); + + const onImageLoad = useCallback(index => () => setLoaded({ ...loaded, [index]: true }), [ + setLoaded, + loaded, + ]); useEffect(() => updateLayout(), [loaded]); useEffect(() => { if (!refs || !refs.current[current] || !loaded[current]) - return setHeight(320); + return setHeight(window.innerHeight - 150); const el = refs.current[current]; - - const element_height = - el.getBoundingClientRect && el.getBoundingClientRect().height; + const element_height = el.getBoundingClientRect && el.getBoundingClientRect().height; setHeight(element_height); }, [refs, current, loaded]); useEffect(() => { const timer = setTimeout(() => setIsAnimated(true), 250); - return () => clearTimeout(timer); }, []); + const onOpenPhotoSwipe = useCallback(() => modalShowPhotoswipe(images, current), [ + modalShowPhotoswipe, + images, + current, + ]); + return (
-
- +
+ {(is_loading || !loaded[0] || !images.length) && ( +
+ +
+ )} -
- {(is_loading || !loaded[0] || !images.length) && ( -
- )} - - {images.map((file, index) => ( -
( +
+ - -
- ))} -
+ onLoad={onImageLoad(index)} + /> +
+ ))}
+ +
); }; diff --git a/src/components/node/NodeImageBlock/styles.scss b/src/components/node/NodeImageBlock/styles.scss index 7e537fa4..a9529f4d 100644 --- a/src/components/node/NodeImageBlock/styles.scss +++ b/src/components/node/NodeImageBlock/styles.scss @@ -1,4 +1,6 @@ .wrap { + padding-bottom: $gap * 2; + &:global(.is_animated) { .image_container { transition: height 0.5s; @@ -12,7 +14,6 @@ .image_container { width: 100%; - background: $node_image_bg; border-radius: $panel_radius 0 0 $panel_radius; display: flex; align-items: center; @@ -22,10 +23,12 @@ user-select: none; .image { - max-height: 960px; + max-height: calc(100vh - 150px); max-width: 100%; opacity: 1; - border-radius: $radius $radius 0 0; + border-radius: $radius; + + @include outer_shadow(); } } @@ -48,6 +51,10 @@ } .placeholder { - background: red; - height: 320px; + width: 100%; + height: calc(100vh - 130px); + border-radius: $radius; + display: flex; + align-items: center; + justify-content: center; } diff --git a/src/components/node/NodeImageSlideBlock/index.tsx b/src/components/node/NodeImageSlideBlock/index.tsx index 7b1f7775..dbdfc494 100644 --- a/src/components/node/NodeImageSlideBlock/index.tsx +++ b/src/components/node/NodeImageSlideBlock/index.tsx @@ -1,33 +1,28 @@ -import React, { - FC, - useMemo, - useState, - useEffect, - useRef, - useCallback, - useLayoutEffect, -} from 'react'; -import { ImageSwitcher } from '../ImageSwitcher'; +import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react'; import * as styles from './styles.scss'; -import { INode } from '~/redux/types'; import classNames from 'classnames'; import { UPLOAD_TYPES } from '~/redux/uploads/constants'; -import { NODE_SETTINGS } from '~/redux/node/constants'; +import { INodeComponentProps } from '~/redux/node/constants'; import { getURL } from '~/utils/dom'; import { PRESETS } from '~/constants/urls'; import { LoaderCircle } from '~/components/input/LoaderCircle'; import { throttle } from 'throttle-debounce'; +import { Icon } from '~/components/input/Icon'; -interface IProps { - is_loading: boolean; - node: INode; - layout: {}; - updateLayout: () => void; -} +interface IProps extends INodeComponentProps {} -const getX = event => (event.touches ? event.touches[0].clientX : event.clientX); +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, updateLayout }) => { +const NodeImageSlideBlock: FC = ({ + node, + is_loading, + is_modal_shown, + updateLayout, + modalShowPhotoswipe, +}) => { const [current, setCurrent] = useState(0); const [height, setHeight] = useState(320); const [max_height, setMaxHeight] = useState(960); @@ -39,6 +34,8 @@ const NodeImageSlideBlock: FC = ({ node, is_loading, updateLayout }) => const [initial_x, setInitialX] = useState(0); const [offset, setOffset] = useState(0); const [is_dragging, setIsDragging] = useState(false); + const [drag_start, setDragStart] = useState(0); + const slide = useRef(); const wrap = useRef(); @@ -162,24 +159,41 @@ const NodeImageSlideBlock: FC = ({ node, is_loading, updateLayout }) => const updateMaxHeight = useCallback(() => { if (!wrap.current) return; const { width } = wrap.current.getBoundingClientRect(); - setMaxHeight(width * NODE_SETTINGS.MAX_IMAGE_ASPECT); + setMaxHeight(window.innerHeight - 143); normalizeOffset(); }, [wrap, setMaxHeight, normalizeOffset]); - const stopDragging = useCallback(() => { - if (!is_dragging) return; + const onOpenPhotoSwipe = useCallback(() => modalShowPhotoswipe(images, current), [ + modalShowPhotoswipe, + images, + current, + ]); - setIsDragging(false); - normalizeOffset(); - }, [setIsDragging, is_dragging, normalizeOffset]); + const stopDragging = useCallback( + event => { + if (!is_dragging) return; + + setIsDragging(false); + normalizeOffset(); + + if ( + Math.abs(new Date().getTime() - drag_start) < 200 && + Math.abs(initial_x - getX(event)) < 5 + ) { + onOpenPhotoSwipe(); + } + }, + [setIsDragging, is_dragging, normalizeOffset, onOpenPhotoSwipe, drag_start] + ); const startDragging = useCallback( event => { setIsDragging(true); setInitialX(getX(event)); setInitialOffset(offset); + setDragStart(new Date().getTime()); }, - [setIsDragging, setInitialX, offset, setInitialOffset] + [setIsDragging, setInitialX, offset, setInitialOffset, setDragStart] ); useEffect(() => updateMaxHeight(), [images]); @@ -214,58 +228,122 @@ const NodeImageSlideBlock: FC = ({ node, is_loading, updateLayout }) => [wrap] ); - return ( -
-
-
- -
-
+ const onPrev = useCallback(() => changeCurrent(current > 0 ? current - 1 : images.length - 1), [ + changeCurrent, + current, + images, + ]); - {!is_loading && ( + const onNext = useCallback(() => changeCurrent(current < images.length - 1 ? current + 1 : 0), [ + changeCurrent, + current, + images, + ]); + + const onKeyDown = useCallback( + event => { + if ( + (event.target.tagName && ['TEXTAREA', 'INPUT'].includes(event.target.tagName)) || + is_modal_shown + ) + return; + + switch (event.key) { + case 'ArrowLeft': + return onPrev(); + case 'ArrowRight': + return onNext(); + } + }, + [onNext, onPrev, is_modal_shown] + ); + + useEffect(() => { + window.addEventListener('keydown', onKeyDown); + + return () => window.removeEventListener('keydown', onKeyDown); + }, [onKeyDown]); + + useEffect(() => { + setOffset(0); + }, [node.id]); + + return ( +
+
+
+
+ +
+
+ +
+ {!is_loading && + images.map((file, index) => ( +
+ +
+ ))} +
+ + {images.length > 1 && ( +
+ {current + 1} + / + {images.length} +
+ )} + + {/* + !is_loading && ( + ) + */} +
+ + {images.length > 1 && ( +
+ +
)} -
- {!is_loading && - images.map((file, index) => ( -
- -
- ))} -
+ {images.length > 1 && ( +
+ +
+ )}
); }; diff --git a/src/components/node/NodeImageSlideBlock/styles.scss b/src/components/node/NodeImageSlideBlock/styles.scss index 4b9874c8..c1d00973 100644 --- a/src/components/node/NodeImageSlideBlock/styles.scss +++ b/src/components/node/NodeImageSlideBlock/styles.scss @@ -1,21 +1,30 @@ .wrap { + position: relative; +} + +.cutter { overflow: hidden; position: relative; min-width: 0; - width: 100%; transition: height 0.25s; - border-radius: $radius $radius 0 0; + border-radius: $radius; + margin-right: -$gap / 2; + margin-left: -$gap / 2; .is_loading { .placeholder { opacity: 1; } } + + @include tablet { + margin-left: 0; + margin-right: 0; + border-radius: 0; + } } .image_container { - // background: $node_image_bg; - border-radius: $panel_radius 0 0 $panel_radius; display: flex; align-items: flex-start; justify-content: flex-start; @@ -24,17 +33,22 @@ user-select: none; will-change: transform, height; transition: height 500ms, transform 500ms; + padding: 0 0 20px 0; &:active { transition: none; } .image { - // max-height: 960px; - max-height: 120vh !important; max-width: 100%; opacity: 1; - border-radius: $radius $radius 0 0; + border-radius: $radius; + box-shadow: transparentize($color: white, $amount: 0.95) 0 -1px, + transparentize($color: #000000, $amount: 0.6) 0 2px 5px; + + @include tablet { + border-radius: 0; + } } &.is_dragging { @@ -44,19 +58,91 @@ .image_wrap { width: 100%; - // top: 0; - // left: 0; - // opacity: 0; pointer-events: none; touch-action: none; z-index: 1; display: flex; align-items: center; justify-content: center; + padding: 0 $gap / 2; + position: relative; &:global(.is_active) { opacity: 1; } + + @include tablet { + padding: 0; + } +} + +.image_count { + position: absolute; + color: transparentize(white, 0.5); + bottom: $gap * 3; + right: 50%; + padding: $gap / 3 $gap; + border-radius: 20px; + background: $content_bg; + font: $font_12_semibold; + transform: translate(50%, 0); + display: flex; + align-items: center; + justify-content: center; + user-select: none; + + small { + font-size: 0.8em; + padding: 0 3px; + } +} + +.image_arrow { + position: absolute; + left: -$gap; + top: 50%; + width: 40px; + height: 64px; + border-radius: $radius; + display: flex; + align-items: center; + justify-content: center; + transform: translate(-100%, -50%); + cursor: pointer; + opacity: 0.7; + transition: opacity 0.5s; + + &:hover { + opacity: 1; + } + + &_right { + right: -$gap; + left: auto; + transform: translate(100%, -50%); + } + + @media (max-width: $content_width + 80px + 40px) { + background: $content_bg; + left: 0; + transform: translate(0, -50%); + border-radius: 0 $radius $radius 0; + + &_right { + right: 0; + left: auto; + border-radius: $radius 0 0 $radius; + } + } + + @include tablet { + display: none; + } + + svg { + position: relative; + left: -2px; + } } .placeholder { diff --git a/src/components/node/NodeNoComments/styles.scss b/src/components/node/NodeNoComments/styles.scss index baebc792..0d95c013 100644 --- a/src/components/node/NodeNoComments/styles.scss +++ b/src/components/node/NodeNoComments/styles.scss @@ -8,7 +8,7 @@ &::after { content: ' '; position: absolute; - background: linear-gradient(transparentize($node_bg, 1), $node_bg); + background: linear-gradient(transparentize($content_bg, 1), $content_bg); border-radius: 0 0 $radius $radius; height: 100px; width: 100%; diff --git a/src/components/node/NodePanel/index.tsx b/src/components/node/NodePanel/index.tsx index 5eed452c..bebb3218 100644 --- a/src/components/node/NodePanel/index.tsx +++ b/src/components/node/NodePanel/index.tsx @@ -47,7 +47,8 @@ const NodePanel: FC = memo( return (
- {stack && + {/* + stack && createPortal( = memo( stack />, document.body - )} + ) + */} = memo( ({ - node: { title, user, is_liked, is_heroic, deleted_at, created_at }, + node: { title, user, is_liked, is_heroic, deleted_at, created_at, like_count }, stack, can_star, @@ -43,36 +43,39 @@ const NodePanelInner: FC = memo( return (
- - -
- {is_loading ? : title || '...'} -
- {user && user.username && ( -
- {is_loading ? ( - - ) : ( - `~${user.username}, ${getPrettyDate(created_at)}` - )} -
- )} -
-
+
+
+ {is_loading ? : title || '...'} +
-
- {can_star && ( -
- {is_heroic ? ( - + {user && user.username && ( +
+ {is_loading ? ( + ) : ( - + `~${user.username.toLocaleLowerCase()}, ${getPrettyDate(created_at)}` )}
)} +
+ + {can_edit && ( +
+
+ +
+ +
+ {can_star && ( +
+ {is_heroic ? ( + + ) : ( + + )} +
+ )} - {can_edit && ( - <>
@@ -80,9 +83,11 @@ const NodePanelInner: FC = memo(
- - )} +
+
+ )} +
{can_like && (
{is_liked ? ( @@ -90,6 +95,8 @@ const NodePanelInner: FC = memo( ) : ( )} + + {like_count > 0 &&
{like_count}
}
)}
diff --git a/src/components/node/NodePanelInner/styles.scss b/src/components/node/NodePanelInner/styles.scss index ee88cbf2..bf515eff 100644 --- a/src/components/node/NodePanelInner/styles.scss +++ b/src/components/node/NodePanelInner/styles.scss @@ -1,19 +1,49 @@ +@mixin button { + margin: 0 $gap; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + svg { + fill: darken(white, 50%); + transition: fill 0.25s; + } + + &:hover { + svg { + fill: $red; + } + } + + &::after { + content: ' '; + flex: 0 0 6px; + height: $gap; + width: 6px; + border-radius: 4px; + background: transparentize(black, 0.7); + margin-left: $gap * 2; + } +} + .wrap { display: flex; align-items: center; justify-content: stretch; position: relative; - // height: 72px; width: 100%; flex-direction: row; align-items: center; justify-content: center; box-sizing: border-box; + min-width: 0; &:global(.stack) { padding: 0 $gap; bottom: 0; position: fixed; + z-index: 5; @include tablet { padding: 0; @@ -28,15 +58,16 @@ justify-content: stretch; border-radius: $radius $radius 0 0; box-sizing: border-box; - padding: $gap; + padding: $gap $gap; background: $node_bg; + height: 64px; + min-width: 0; @include outer_shadow(); @include tablet { border-radius: 0; - flex-direction: column; - align-items: flex-start; + height: auto; } @include can_backdrop { @@ -48,20 +79,25 @@ .title { text-transform: uppercase; font: $font_24_semibold; - // height: 24px; - padding-bottom: 6px; + overflow: hidden; + flex: 1; + text-overflow: ellipsis; @include tablet { - // font-size: 16px; - word-break: break-word; + white-space: nowrap; padding-bottom: 0; - padding-top: 10px; + font: $font_20_semibold; } } .name { font: $font_14_regular; color: transparentize(white, 0.5); + text-transform: lowercase; + + @include tablet { + font: $font_12_regular; + } } .btn { @@ -75,9 +111,11 @@ .panel { flex: 1; + min-width: 0; } -.buttons { +.buttons, +.editor_buttons { flex: 0; padding-right: $gap; fill: transparentize(white, 0.7); @@ -87,48 +125,43 @@ justify-content: center; & > * { - margin: 0 $gap; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; + @include button; + } - svg { - fill: darken(white, 50%); - transition: fill 0.25s; - } - - &:hover { - svg { - fill: $red; - } - } - - &::after { - content: ' '; - flex: 0 0 6px; - height: $gap; - width: 6px; - border-radius: 4px; - background: transparentize(black, 0.7); - margin-left: $gap * 2; - } - - &:first-child { - margin-left: 0; - } + @include tablet { + align-self: center; + } +} +.buttons { + & > * { &:last-child { margin-right: 0; + &::after { display: none; } } } +} +.editor_buttons { @include tablet { - margin-top: $gap * 2; - align-self: center; + display: none; + + & > * { + &:last-child { + margin-right: 0; + + &::after { + display: none; + } + } + + &:first-child { + margin-left: 0; + } + } } } @@ -181,19 +214,44 @@ .like { transition: fill, stroke 0.25s; will-change: transform; + position: relative; &:global(.is_liked) { svg { fill: $red; } + + .like_count { + color: $red; + } } &:hover { fill: $red; animation: pulse 0.75s infinite; + + .like_count { + opacity: 0; + } } } +.like_count { + position: absolute; + font: $font_12_bold; + left: 16px; + bottom: 0; + opacity: 1; + transition: opacity 0.25s, color 0.25s; + background: $node_bg; + padding: 0 3px; + border-radius: 10px; + z-index: 3; + color: transparentize($color: white, $amount: 0.5); + pointer-events: none; + touch-action: none; +} + .star { transition: fill, stroke 0.25s; will-change: transform; @@ -208,3 +266,31 @@ fill: $orange; } } + +.editor_menu_button { + display: none !important; + + @include button(); + + @include tablet { + display: flex !important; + } +} + +.editor_menu { + &:hover { + .editor_buttons { + @include tablet { + display: flex; + position: absolute; + right: 0; + top: 100%; + background: darken($content_bg, 4%); + padding: $gap * 2; + border-radius: $radius; + box-shadow: transparentize(black, 0.8) 5px 5px 5px; + transform: translate(0, -10px); + } + } + } +} diff --git a/src/components/node/NodeRelated/index.tsx b/src/components/node/NodeRelated/index.tsx index 07c70da7..f119d9f2 100644 --- a/src/components/node/NodeRelated/index.tsx +++ b/src/components/node/NodeRelated/index.tsx @@ -13,10 +13,9 @@ const NodeRelated: FC = ({ title, items }) => { return (
-
{title}
-
+
{items.map(item => ( diff --git a/src/components/node/NodeRelated/styles.scss b/src/components/node/NodeRelated/styles.scss index f11b52ac..1a47f904 100644 --- a/src/components/node/NodeRelated/styles.scss +++ b/src/components/node/NodeRelated/styles.scss @@ -17,24 +17,13 @@ grid-template-columns: repeat(6, 1fr); } } -.title { - font: $font_14_semibold; - text-transform: uppercase; - color: transparentize(white, 0.3); - flex-direction: row; - display: flex; - align-items: center; -} -.line { - display: none; - flex: 1; - height: 2px; - background: transparentize(white, 0.95); +.title { + @include title_with_line(); } .text { - margin: 0 $gap; + margin-left: $gap / 2; } .placeholder { diff --git a/src/components/node/NodeRelatedItem/index.tsx b/src/components/node/NodeRelatedItem/index.tsx index ebdfbdf8..982f62e9 100644 --- a/src/components/node/NodeRelatedItem/index.tsx +++ b/src/components/node/NodeRelatedItem/index.tsx @@ -1,10 +1,10 @@ -import React, { FC, memo, useCallback, useState } from 'react'; +import React, { FC, memo, useCallback, useState, useMemo } from 'react'; import * as styles from './styles.scss'; import classNames from 'classnames'; import { INode } from '~/redux/types'; import { URLS, PRESETS } from '~/constants/urls'; import { RouteComponentProps, withRouter } from 'react-router'; -import { getURL } from '~/utils/dom'; +import { getURL, stringToColour } from '~/utils/dom'; type IProps = RouteComponentProps & { item: Partial; @@ -28,6 +28,15 @@ const NodeRelatedItemUnconnected: FC = memo(({ item, history }) => { const [is_loaded, setIsLoaded] = useState(false); const onClick = useCallback(() => history.push(URLS.NODE_URL(item.id)), [item, history]); + const thumb = useMemo( + () => (item.thumbnail ? getURL({ url: item.thumbnail }, PRESETS.avatar) : ''), + [item] + ); + const backgroundColor = useMemo( + () => (!thumb && item.title && stringToColour(item.title)) || '', + [] + ); + return (
= memo(({ item, history }) => { >
- {!item.thumbnail &&
{getTitleLetters(item.title)}
} + {!item.thumbnail && ( +
+ {getTitleLetters(item.title)} +
+ )} = ({ node }) => (
= ({ node }) => { const video = useMemo(() => { diff --git a/src/constants/api.ts b/src/constants/api.ts index 7b381290..e4b2f40b 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -30,4 +30,13 @@ export const API = { `/node/${id}/comment/${comment_id}/lock`, SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`, }, + SEARCH: { + NODES: '/search/nodes', + }, + EMBED: { + YOUTUBE: '/embed/youtube', + }, + BORIS: { + GET_BACKEND_STATS: '/stats', + }, }; diff --git a/src/constants/comment.ts b/src/constants/comment.ts index 916f18a2..9689c167 100644 --- a/src/constants/comment.ts +++ b/src/constants/comment.ts @@ -27,6 +27,10 @@ export type ICommentBlock = { content: string; }; +export type ICommentBlockProps = { + block: ICommentBlock; +}; + export const COMMENT_BLOCK_RENDERERS = { [COMMENT_BLOCK_TYPES.TEXT]: CommentTextBlock, [COMMENT_BLOCK_TYPES.MARK]: CommentTextBlock, diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index 0fd36b11..82b52a1e 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -10,6 +10,7 @@ import { ProfileDialog } from '~/containers/dialogs/ProfileDialog'; import { RestoreRequestDialog } from '~/containers/dialogs/RestoreRequestDialog'; import { RestorePasswordDialog } from '~/containers/dialogs/RestorePasswordDialog'; import { DIALOGS } from '~/redux/modal/constants'; +import { PhotoSwipe } from '~/containers/dialogs/PhotoSwipe'; export const DIALOG_CONTENT = { [DIALOGS.EDITOR_IMAGE]: EditorDialogImage, @@ -22,6 +23,7 @@ export const DIALOG_CONTENT = { [DIALOGS.PROFILE]: ProfileDialog, [DIALOGS.RESTORE_REQUEST]: RestoreRequestDialog, [DIALOGS.RESTORE_PASSWORD]: RestorePasswordDialog, + [DIALOGS.PHOTOSWIPE]: PhotoSwipe, }; export const NODE_EDITOR_DIALOGS = { diff --git a/src/constants/phrases.ts b/src/constants/phrases.ts index e1d13fb3..61b4d2f0 100644 --- a/src/constants/phrases.ts +++ b/src/constants/phrases.ts @@ -18,8 +18,19 @@ export const PHRASES = { 'Роботы, несомненно, изредка видят сны об электроовцах. И не только.', 'Постарайтесь забыть о хурме как можно скорее. Хурма пагубна и коварна.', 'Возможно, именно сейчас вы спите, и всё происходящее - лишь глупый сон. Но подумайте, стоит ли щипать себя почём зря?', + 'Фыфывдыфвдфывфыф ывфы фывфывфы ахахаха, о даааа!', + 'Дид ай толд ю вэт ай лав ю? Ноу, рили?', + 'У нас тут такое не только не приветствуется, но и всячески... Эй, это кабачок?', + ], + BORIS_TITLE: [ + 'Снова вместе', + 'Я видел это во сне', + 'Что тут у нас?', + 'Мы скучали, а ты?', + "Here's Boris!", + 'Боброборцы - вперёд!', + 'Супротив и вопреки', ], - BORIS_TITLE: ['Снова вместе', 'Я видел это во сне', 'Что тут у нас?'], NOTHING_HERE: [ 'Тут пусто и одиноко', 'Совсем ничего', diff --git a/src/constants/urls.ts b/src/constants/urls.ts index 28813cd3..db07a4ce 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -14,7 +14,7 @@ export const URLS = { }, NODE_URL: (id: number | string) => `/post${id}`, PROFILE: (username: string) => `/~${username}`, - PROFILE_PAGE: `/profile`, + PROFILE_PAGE: (username: string) => `/profile/${username}`, }; export const PRESETS = { diff --git a/src/containers/App.tsx b/src/containers/App.tsx index c534a844..a67ca5e5 100644 --- a/src/containers/App.tsx +++ b/src/containers/App.tsx @@ -6,8 +6,6 @@ import { Switch, Route, Redirect } from 'react-router-dom'; import { history } from '~/redux/store'; import { FlowLayout } from '~/containers/flow/FlowLayout'; import { MainLayout } from '~/containers/main/MainLayout'; -import { ImageExample } from '~/containers/examples/ImageExample'; -import { EditorExample } from '~/containers/examples/EditorExample'; import { Sprites } from '~/sprites/Sprites'; import { URLS } from '~/constants/urls'; import { Modal } from '~/containers/dialogs/Modal'; @@ -39,12 +37,10 @@ const Component: FC = ({ modal: { is_shown } }) => { - - - + @@ -57,7 +53,4 @@ const Component: FC = ({ modal: { is_shown } }) => { ); }; -export default connect( - mapStateToProps, - mapDispatchToProps -)(hot(module)(Component)); +export default connect(mapStateToProps, mapDispatchToProps)(hot(module)(Component)); diff --git a/src/containers/dialogs/PhotoSwipe/index.tsx b/src/containers/dialogs/PhotoSwipe/index.tsx new file mode 100644 index 00000000..ec6bfdba --- /dev/null +++ b/src/containers/dialogs/PhotoSwipe/index.tsx @@ -0,0 +1,130 @@ +import React, { FC, useRef, useEffect, useMemo, useCallback } from 'react'; +import { connect } from 'react-redux'; + +import PhotoSwipeJs from 'photoswipe/dist/photoswipe.js'; +import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default.js'; +import 'photoswipe/dist/photoswipe.css'; +import 'photoswipe/dist/default-skin/default-skin.css'; +import { IState } from '~/redux/store'; +import { selectModal } from '~/redux/modal/selectors'; +import { getURL } from '~/utils/dom'; +import { PRESETS } from '~/constants/urls'; +import * as MODAL_ACTIONS from '~/redux/modal/actions'; +import styles from './styles.scss'; +import classNames from 'classnames'; + +const mapStateToProps = (state: IState) => ({ + photoswipe: selectModal(state).photoswipe, +}); + +const mapDispatchToProps = { + modalSetShown: MODAL_ACTIONS.modalSetShown, +}; + +type Props = ReturnType & typeof mapDispatchToProps & {}; + +const PhotoSwipeUnconnected: FC = ({ photoswipe, modalSetShown }) => { + let ref = useRef(null); + + const closeModal = useCallback(() => modalSetShown(false), [modalSetShown]); + + useEffect(() => { + new Promise(async resolve => { + const images = await Promise.all( + photoswipe.images.map( + image => + new Promise(resolveImage => { + const img = new Image(); + + img.onload = () => { + resolveImage({ + src: getURL(image, window.innerWidth < 768 ? PRESETS[900] : PRESETS[1600]), + h: img.naturalHeight, + w: img.naturalWidth, + }); + }; + + img.onerror = () => { + resolveImage({}); + }; + + img.src = getURL(image, PRESETS[1600]); + }) + ) + ); + + resolve(images); + }).then(images => { + const ps = new PhotoSwipeJs(ref.current, PhotoSwipeUI_Default, images, { + index: photoswipe.index || 0, + closeOnScroll: false, + history: false, + }); + + ps.init(); + ps.listen('destroy', closeModal); + ps.listen('close', closeModal); + }); + }, [photoswipe.images, photoswipe.index]); + + const closeOnHashChange = useCallback(() => { + if (window.location.hash !== '#preview') return closeModal(); + }, [closeModal]); + + useEffect(() => { + window.addEventListener('hashchange', closeOnHashChange); + return () => window.removeEventListener('hashchange', closeOnHashChange); + }, [closeOnHashChange]); + + useEffect(() => { + window.location.hash = 'preview'; + return () => (window.location.hash = ''); + }, []); + + return ( +