mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-05-04 09:06:40 +07:00
Merge branch 'master' into feature/go-backend
This commit is contained in:
commit
77f7bc8af2
133 changed files with 14031 additions and 1801 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,3 +3,4 @@
|
||||||
/npm-debug.log
|
/npm-debug.log
|
||||||
/.idea
|
/.idea
|
||||||
/.env
|
/.env
|
||||||
|
/dist
|
49
Jenkinsfile
vendored
Normal file
49
Jenkinsfile
vendored
Normal file
|
@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
11
custom.d.ts
vendored
11
custom.d.ts
vendored
|
@ -1,14 +1,19 @@
|
||||||
declare module "*.svg" {
|
declare module '*.svg' {
|
||||||
const content: any;
|
const content: any;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.scss' {
|
declare module '*.scss' {
|
||||||
const content: {[className: string]: string};
|
const content: { [className: string]: string };
|
||||||
export = content;
|
export = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.less' {
|
declare module '*.less' {
|
||||||
const content: {[className: string]: string};
|
const content: { [className: string]: string };
|
||||||
export = content;
|
export = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.json' {
|
||||||
|
const content: any;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
22
package.json
22
package.json
|
@ -14,13 +14,13 @@
|
||||||
"url": "https://github.com/muerwre/my-empty-react-project"
|
"url": "https://github.com/muerwre/my-empty-react-project"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/cli": "^7.6.4",
|
||||||
"@babel/preset-env": "^7.6.3",
|
"@babel/preset-env": "^7.6.3",
|
||||||
"@babel/types": "7.5.5",
|
"@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",
|
"@types/react-router": "^5.1.2",
|
||||||
"autoresponsive-react": "^1.1.31",
|
"autoresponsive-react": "^1.1.31",
|
||||||
"awesome-typescript-loader": "^5.2.1",
|
"awesome-typescript-loader": "^5.2.1",
|
||||||
|
@ -85,6 +85,7 @@
|
||||||
"less-middleware": "~2.2.1",
|
"less-middleware": "~2.2.1",
|
||||||
"lodash": "^4.17.10",
|
"lodash": "^4.17.10",
|
||||||
"node-sass": "^4.11.0",
|
"node-sass": "^4.11.0",
|
||||||
|
"photoswipe": "^4.1.3",
|
||||||
"raleway-cyrillic": "^4.0.2",
|
"raleway-cyrillic": "^4.0.2",
|
||||||
"ramda": "^0.26.1",
|
"ramda": "^0.26.1",
|
||||||
"react": "16.13.0",
|
"react": "16.13.0",
|
||||||
|
@ -94,21 +95,32 @@
|
||||||
"react-redux": "^6.0.1",
|
"react-redux": "^6.0.1",
|
||||||
"react-router": "^5.1.2",
|
"react-router": "^5.1.2",
|
||||||
"react-router-dom": "^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": "^4.0.1",
|
||||||
"redux-persist": "^5.10.0",
|
"redux-persist": "^5.10.0",
|
||||||
"redux-saga": "^1.1.1",
|
"redux-saga": "^1.1.1",
|
||||||
"reduxsauce": "^1.0.0",
|
"reduxsauce": "^1.0.0",
|
||||||
|
"resize-sensor": "^0.0.6",
|
||||||
"sass-loader": "^7.3.1",
|
"sass-loader": "^7.3.1",
|
||||||
"sass-resources-loader": "^2.0.0",
|
"sass-resources-loader": "^2.0.0",
|
||||||
"scrypt": "^6.0.3",
|
"scrypt": "^6.0.3",
|
||||||
"sticky-sidebar": "^3.3.1",
|
"sticky-sidebar": "^3.3.1",
|
||||||
"throttle-debounce": "^2.1.0",
|
"throttle-debounce": "^2.1.0",
|
||||||
|
"tinycolor": "^0.0.1",
|
||||||
"tslint": "^5.20.0",
|
"tslint": "^5.20.0",
|
||||||
"tslint-config-airbnb": "^5.11.2",
|
"tslint-config-airbnb": "^5.11.2",
|
||||||
"tslint-react": "^4.1.0",
|
"tslint-react": "^4.1.0",
|
||||||
"tslint-react-hooks": "^2.2.1",
|
"tslint-react-hooks": "^2.2.1",
|
||||||
"tt-react-custom-scrollbars": "latest",
|
"tt-react-custom-scrollbars": "latest",
|
||||||
"uuid4": "^1.1.4"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
19
src/components/boris/BorisStats/index.tsx
Normal file
19
src/components/boris/BorisStats/index.tsx
Normal file
|
@ -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<IProps> = ({ stats }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BorisStatsBackend stats={stats} />
|
||||||
|
<BorisStatsGit stats={stats} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BorisStats };
|
74
src/components/boris/BorisStatsBackend/index.tsx
Normal file
74
src/components/boris/BorisStatsBackend/index.tsx
Normal file
|
@ -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<IProps> = ({ stats }) => {
|
||||||
|
if (stats.is_loading)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<Placeholder width="50%" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stats.backend) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.title}>Юнитс</div>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
В сознании <span>{stats.backend.users.alive}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Криокамера <span>{stats.backend.users.total - stats.backend.users.alive}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className={styles.title}>Контент</div>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Фотографии <span>{stats.backend.nodes.images}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Письма <span>{stats.backend.nodes.texts}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Видеозаписи <span>{stats.backend.nodes.videos}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Аудиозаписи <span>{stats.backend.nodes.audios}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Комментарии <span>{stats.backend.comments.total}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className={styles.title}>Сторедж</div>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Файлы <span>{stats.backend.files.count}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
На диске <span>{sizeOf(stats.backend.files.size)}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BorisStatsBackend };
|
36
src/components/boris/BorisStatsBackend/styles.module.scss
Normal file
36
src/components/boris/BorisStatsBackend/styles.module.scss
Normal file
|
@ -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;
|
||||||
|
}
|
45
src/components/boris/BorisStatsGit/index.tsx
Normal file
45
src/components/boris/BorisStatsGit/index.tsx
Normal file
|
@ -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<IProps> = ({ stats }) => {
|
||||||
|
if (!stats.git.length) return null;
|
||||||
|
|
||||||
|
if (stats.is_loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.stats__title}>
|
||||||
|
<Placeholder width="50%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Placeholder width="50%" />
|
||||||
|
<Placeholder width="100%" />
|
||||||
|
<Placeholder width="50%" />
|
||||||
|
<Placeholder width="70%" />
|
||||||
|
<Placeholder width="60%" />
|
||||||
|
<Placeholder width="100%" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.stats__title}>КОММИТС</div>
|
||||||
|
|
||||||
|
{stats.git
|
||||||
|
.filter(data => data.commit && data.timestamp && data.subject)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(data => (
|
||||||
|
<BorisStatsGitCard data={data} key={data.commit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BorisStatsGit };
|
8
src/components/boris/BorisStatsGit/styles.module.scss
Normal file
8
src/components/boris/BorisStatsGit/styles.module.scss
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.stats {
|
||||||
|
&__title {
|
||||||
|
font: $font_12_semibold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin: $gap * 2 0 $gap;
|
||||||
|
}
|
||||||
|
}
|
24
src/components/boris/BorisStatsGitCard/index.tsx
Normal file
24
src/components/boris/BorisStatsGitCard/index.tsx
Normal file
|
@ -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<IProps> = ({ data: { timestamp, subject } }) => {
|
||||||
|
if (!subject || !timestamp) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.time}>
|
||||||
|
{getPrettyDate(new Date(parseInt(`${timestamp}000`)).toISOString())}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.subject}>{subject}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BorisStatsGitCard };
|
18
src/components/boris/BorisStatsGitCard/styles.module.scss
Normal file
18
src/components/boris/BorisStatsGitCard/styles.module.scss
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -1,31 +1,70 @@
|
||||||
import React, { FC, memo, useMemo } from 'react';
|
import React, { FC, memo, useMemo, useEffect } from 'react';
|
||||||
import { ICommentBlock } from '~/constants/comment';
|
import { ICommentBlockProps } from '~/constants/comment';
|
||||||
import styles from './styles.scss';
|
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';
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
interface IProps {
|
const mapStateToProps = state => ({
|
||||||
block: ICommentBlock;
|
youtubes: selectPlayer(state).youtubes,
|
||||||
}
|
});
|
||||||
|
|
||||||
const CommentEmbedBlock: FC<IProps> = memo(({ block }) => {
|
const mapDispatchToProps = {
|
||||||
const link = block.content.match(
|
playerGetYoutubeInfo: PLAYER_ACTIONS.playerGetYoutubeInfo,
|
||||||
/(https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?[\w\-\&\=]+)/gi
|
};
|
||||||
|
|
||||||
|
type Props = ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps &
|
||||||
|
ICommentBlockProps & {};
|
||||||
|
|
||||||
|
const CommentEmbedBlockUnconnected: FC<Props> = 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]);
|
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 (
|
return (
|
||||||
<div className={styles.embed}>
|
<div className={styles.embed}>
|
||||||
<a href={link[0]} target="_blank" />
|
<a href={link[0]} target="_blank" />
|
||||||
|
|
||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
<div style={{ backgroundImage: `url("${preview}")` }}>
|
<div style={{ backgroundImage: `url("${preview}")` }}>
|
||||||
<div className={styles.backdrop}>{link[0]}</div>
|
<div className={styles.backdrop}>
|
||||||
|
<div className={styles.play}>
|
||||||
|
<Icon icon="play" size={32} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.title}>{title || link[0]}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommentEmbedBlock = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(CommentEmbedBlockUnconnected);
|
||||||
|
|
||||||
export { CommentEmbedBlock };
|
export { CommentEmbedBlock };
|
||||||
|
|
|
@ -9,7 +9,15 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
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 {
|
a {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -33,21 +41,22 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: transparentize(black, 0.5);
|
background: transparentize($comment_bg, 0.15) 50% 50%;
|
||||||
|
background-size: cover;
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font: $font_16_medium;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
@include can_backdrop {
|
@include outer_shadow();
|
||||||
background: transparentize(black, 0.3);
|
|
||||||
backdrop-filter: blur(5px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
padding: 0 $gap $gap / 2;
|
padding: 0 $gap / 2 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -63,5 +72,27 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
position: relative;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { ICommentBlock } from '~/constants/comment';
|
import { ICommentBlockProps } from '~/constants/comment';
|
||||||
import styles from './styles.scss';
|
import styles from './styles.scss';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends ICommentBlockProps {}
|
||||||
block: ICommentBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommentTextBlock: FC<IProps> = ({ block }) => {
|
const CommentTextBlock: FC<IProps> = ({ block }) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -7,8 +7,34 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
color: #cccccc;
|
color: #cccccc;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
b {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
.blur {
|
.blur {
|
||||||
filter: blur(0);
|
|
||||||
transition: filter 0.25s;
|
|
||||||
will-change: filter;
|
|
||||||
padding-top: $header_height + 2px;
|
padding-top: $header_height + 2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
animation: fadeIn 2s;
|
animation: fadeIn 2s;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
|
filter: blur(10px);
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
|
@ -26,7 +27,7 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
@include tablet {
|
||||||
|
|
41
src/components/containers/Sticky/index.tsx
Normal file
41
src/components/containers/Sticky/index.tsx
Normal file
|
@ -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<HTMLDivElement> {}
|
||||||
|
|
||||||
|
(window as any).StickySidebar = StickySidebar;
|
||||||
|
(window as any).ResizeSensor = ResizeSensor;
|
||||||
|
|
||||||
|
const Sticky: FC<IProps> = ({ 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 (
|
||||||
|
<div className={classnames(styles.wrap, 'sidebar_container')}>
|
||||||
|
<div className="sidebar" ref={ref}>
|
||||||
|
<div className={classnames(styles.sticky, 'sidebar__inner')}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Sticky };
|
15
src/components/containers/Sticky/styles.scss
Normal file
15
src/components/containers/Sticky/styles.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import { PRESETS } from '~/constants/urls';
|
||||||
import { debounce } from 'throttle-debounce';
|
import { debounce } from 'throttle-debounce';
|
||||||
import { NODE_TYPES } from '~/redux/node/constants';
|
import { NODE_TYPES } from '~/redux/node/constants';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const THUMBNAIL_SIZES = {
|
const THUMBNAIL_SIZES = {
|
||||||
horizontal: PRESETS.small_hero,
|
horizontal: PRESETS.small_hero,
|
||||||
|
@ -67,6 +68,7 @@ const Cell: FC<IProps> = ({
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}, [setIsLoaded]);
|
}, [setIsLoaded]);
|
||||||
|
|
||||||
|
// Replaced it with <Link>, maybe, you can remove it completely with NodeSelect action
|
||||||
const onClick = useCallback(() => onSelect(id, type), [onSelect, id, type]);
|
const onClick = useCallback(() => onSelect(id, type), [onSelect, id, type]);
|
||||||
const has_description = description && description.length > 32;
|
const has_description = description && description.length > 32;
|
||||||
|
|
||||||
|
@ -130,7 +132,7 @@ const Cell: FC<IProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={classNames(styles.face)} onClick={onClick}>
|
<Link className={classNames(styles.face)} to={`/post${id}`}>
|
||||||
<div className={styles.face_content}>
|
<div className={styles.face_content}>
|
||||||
{title && !text && <div className={styles.title}>{title}</div>}
|
{title && !text && <div className={styles.title}>{title}</div>}
|
||||||
|
|
||||||
|
@ -150,7 +152,7 @@ const Cell: FC<IProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{thumbnail && (
|
{thumbnail && (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
color: white;
|
color: white;
|
||||||
background: 50% 50% no-repeat $content_bg;
|
background: 50% 50% no-repeat $content_bg;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
.is_hero {
|
.is_hero {
|
||||||
.title {
|
.title {
|
||||||
|
@ -23,14 +24,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.thumbnail {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, 10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@include outer_shadow();
|
@include outer_shadow();
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
font: $font_18_regular;
|
font: $font_18_regular;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
// margin-top: $gap;
|
|
||||||
// letter-spacing: 0.5px;
|
|
||||||
background: transparentize($color: $content_bg, $amount: 0.3) url('~/sprites/stripes.svg');
|
background: transparentize($color: $content_bg, $amount: 0.3) url('~/sprites/stripes.svg');
|
||||||
padding: $gap;
|
padding: $gap;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
@ -68,7 +77,7 @@
|
||||||
.title,
|
.title,
|
||||||
.text_title {
|
.text_title {
|
||||||
font: $font_cell_title;
|
font: $font_cell_title;
|
||||||
line-height: 1.1em;
|
line-height: 1.25em;
|
||||||
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -83,7 +92,9 @@
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
padding: $gap / 2;
|
padding: $gap / 2;
|
||||||
// max-height: 3.3em;
|
opacity: 1;
|
||||||
|
transform: translate(0, 0);
|
||||||
|
transition: opacity 0.5s, transform 1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text_title {
|
.text_title {
|
||||||
|
@ -146,7 +157,7 @@
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
border-radius: $cell_radius + 2px;
|
border-radius: $cell_radius + 2px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s, transform 1s;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
|
||||||
& > img {
|
& > img {
|
||||||
|
@ -180,82 +191,9 @@
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
border-radius: $cell_radius;
|
border-radius: $cell_radius;
|
||||||
padding: $gap / 2;
|
padding: $gap / 2;
|
||||||
// pointer-events: none;
|
|
||||||
// touch-action: none;
|
|
||||||
animation: appear 1s forwards;
|
animation: appear 1s forwards;
|
||||||
|
color: white;
|
||||||
// @media (min-width: $cell * 2 + $grid_line) {
|
text-decoration: none;
|
||||||
// .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;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { Cell } from '~/components/flow/Cell';
|
import { Cell } from '~/components/flow/Cell';
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { IFlowState } from '~/redux/flow/reducer';
|
import { IFlowState } from '~/redux/flow/reducer';
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { canEditNode } from '~/utils/node';
|
import { canEditNode } from '~/utils/node';
|
||||||
import { IUser } from '~/redux/auth/types';
|
import { IUser } from '~/redux/auth/types';
|
||||||
import { flowSetCellView } from '~/redux/flow/actions';
|
import { flowSetCellView } from '~/redux/flow/actions';
|
||||||
import { FlowHero } from '../FlowHero';
|
|
||||||
import { FlowRecent } from '../FlowRecent';
|
|
||||||
|
|
||||||
type IProps = Partial<IFlowState> & {
|
type IProps = Partial<IFlowState> & {
|
||||||
user: Partial<IUser>;
|
user: Partial<IUser>;
|
||||||
|
@ -16,24 +13,8 @@ type IProps = Partial<IFlowState> & {
|
||||||
onChangeCellView: typeof flowSetCellView;
|
onChangeCellView: typeof flowSetCellView;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FlowGrid: FC<IProps> = ({
|
export const FlowGrid: FC<IProps> = ({ user, nodes, onSelect, onChangeCellView }) => (
|
||||||
user,
|
<>
|
||||||
nodes,
|
|
||||||
heroes,
|
|
||||||
recent,
|
|
||||||
updated,
|
|
||||||
onSelect,
|
|
||||||
onChangeCellView,
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
<div className={styles.grid_test}>
|
|
||||||
<div className={styles.hero}>
|
|
||||||
<FlowHero heroes={heroes} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.stamp}>
|
|
||||||
<FlowRecent recent={recent} updated={updated} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{nodes.map(node => (
|
{nodes.map(node => (
|
||||||
<Cell
|
<Cell
|
||||||
key={node.id}
|
key={node.id}
|
||||||
|
@ -43,6 +24,5 @@ export const FlowGrid: FC<IProps> = ({
|
||||||
onChangeCellView={onChangeCellView}
|
onChangeCellView={onChangeCellView}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -16,10 +16,9 @@ const FlowHeroUnconnected: FC<IProps> = ({ heroes, history }) => {
|
||||||
const [limit, setLimit] = useState(Math.min(heroes.length, 6));
|
const [limit, setLimit] = useState(Math.min(heroes.length, 6));
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [loaded, setLoaded] = useState([]);
|
const [loaded, setLoaded] = useState([]);
|
||||||
|
|
||||||
const timer = useRef(null);
|
const timer = useRef(null);
|
||||||
|
|
||||||
const onLoad = useCallback(id => () => setLoaded([...loaded, id]), [setLoaded, loaded]);
|
const onLoad = useCallback(id => () => setLoaded([...loaded, id]), [setLoaded, loaded]);
|
||||||
|
|
||||||
const onNext = useCallback(() => {
|
const onNext = useCallback(() => {
|
||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
|
|
||||||
|
@ -47,9 +46,7 @@ const FlowHeroUnconnected: FC<IProps> = ({ heroes, history }) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
timer.current = setTimeout(onNext, 5000);
|
timer.current = setTimeout(onNext, 5000);
|
||||||
|
}, [current, onNext]);
|
||||||
return () => clearTimeout(timer.current);
|
|
||||||
}, [current]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current === 0 && loaded.length > 0) setCurrent(loaded[0]);
|
if (current === 0 && loaded.length > 0) setCurrent(loaded[0]);
|
||||||
|
@ -80,6 +77,8 @@ const FlowHeroUnconnected: FC<IProps> = ({ heroes, history }) => {
|
||||||
return item.title;
|
return item.title;
|
||||||
}, [loaded, current, heroes]);
|
}, [loaded, current, heroes]);
|
||||||
|
|
||||||
|
const preset = useMemo(() => (window.innerWidth <= 768 ? PRESETS.cover : PRESETS.small_hero), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap} onMouseOver={stopSliding} onFocus={stopSliding}>
|
<div className={styles.wrap} onMouseOver={stopSliding} onFocus={stopSliding}>
|
||||||
{loaded && loaded.length > 0 && (
|
{loaded && loaded.length > 0 && (
|
||||||
|
@ -104,13 +103,13 @@ const FlowHeroUnconnected: FC<IProps> = ({ heroes, history }) => {
|
||||||
[styles.is_active]: current === hero.id,
|
[styles.is_active]: current === hero.id,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url("${getURL({ url: hero.thumbnail }, PRESETS.small_hero)}")`,
|
backgroundImage: `url("${getURL({ url: hero.thumbnail }, preset)}")`,
|
||||||
}}
|
}}
|
||||||
key={hero.id}
|
key={hero.id}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getURL({ url: hero.thumbnail }, PRESETS.small_hero)}
|
src={getURL({ url: hero.thumbnail }, preset)}
|
||||||
alt={hero.thumbnail}
|
alt={hero.thumbnail}
|
||||||
onLoad={onLoad(hero.id)}
|
onLoad={onLoad(hero.id)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,12 +1,3 @@
|
||||||
// @keyframes rise {
|
|
||||||
// 0% {
|
|
||||||
// transform: translate(0, 0);
|
|
||||||
// }
|
|
||||||
// 100% {
|
|
||||||
// transform: translate(0, -10%);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -16,20 +7,22 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: " ";
|
content: ' ';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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;
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
box-shadow: inset transparentize($color: white, $amount: 0.85) 0 1px;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
border-radius: $radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: " ";
|
content: ' ';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -94,6 +87,7 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title_wrap {
|
.title_wrap {
|
||||||
|
@ -105,19 +99,28 @@
|
||||||
font: $font_hero_title;
|
font: $font_hero_title;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-overflow: ellipsis;
|
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 {
|
// .title {
|
||||||
flex: 0;
|
// flex: 0;
|
||||||
height: 48px;
|
// height: 48px;
|
||||||
display: flex;
|
// display: flex;
|
||||||
align-items: center;
|
// align-items: center;
|
||||||
justify-content: center;
|
// justify-content: center;
|
||||||
padding: 0 $gap 0 0;
|
// padding: 0 $gap 0 0;
|
||||||
border-radius: $radius;
|
// background: red;
|
||||||
font: $font_hero_title;
|
// border-radius: $radius;
|
||||||
text-transform: uppercase;
|
// font: $font_hero_title;
|
||||||
}
|
// text-transform: uppercase;
|
||||||
|
// }
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,48 +1,20 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { IFlowState } from '~/redux/flow/reducer';
|
import { IFlowState } from '~/redux/flow/reducer';
|
||||||
import { getURL, getPrettyDate } from '~/utils/dom';
|
import { FlowRecentItem } from '../FlowRecentItem';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { URLS, PRESETS } from '~/constants/urls';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
recent: IFlowState['recent'];
|
recent: IFlowState['recent'];
|
||||||
updated: IFlowState['updated'];
|
updated: IFlowState['updated'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlowRecent: FC<IProps> = ({ recent, updated }) => (
|
const FlowRecent: FC<IProps> = ({ recent, updated }) => {
|
||||||
<div className={styles.grid}>
|
return (
|
||||||
{updated &&
|
<>
|
||||||
updated.slice(0, 20).map(node => (
|
{updated && updated.map(node => <FlowRecentItem node={node} key={node.id} has_new />)}
|
||||||
<Link key={node.id} className={styles.item} to={URLS.NODE_URL(node.id)}>
|
|
||||||
<div
|
|
||||||
className={classNames(styles.thumb, styles.new)}
|
|
||||||
style={{ backgroundImage: `url("${getURL({ url: node.thumbnail }, PRESETS.avatar)}")` }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.info}>
|
{recent && recent.map(node => <FlowRecentItem node={node} key={node.id} />)}
|
||||||
<div className={styles.title}>{node.title}</div>
|
</>
|
||||||
<div className={styles.comment}>{getPrettyDate(node.created_at)}</div>
|
);
|
||||||
</div>
|
};
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{recent &&
|
|
||||||
recent.slice(0, 20).map(node => (
|
|
||||||
<Link key={node.id} className={styles.item} to={URLS.NODE_URL(node.id)}>
|
|
||||||
<div className={styles.thumb}>
|
|
||||||
<NodeRelatedItem item={node} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.info}>
|
|
||||||
<div className={styles.title}>{node.title}</div>
|
|
||||||
<div className={styles.comment}>{getPrettyDate(node.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export { FlowRecent };
|
export { FlowRecent };
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
30
src/components/flow/FlowRecentItem/index.tsx
Normal file
30
src/components/flow/FlowRecentItem/index.tsx
Normal file
|
@ -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<INode>;
|
||||||
|
has_new?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FlowRecentItem: FC<IProps> = ({ node, has_new }) => {
|
||||||
|
return (
|
||||||
|
<Link key={node.id} className={styles.item} to={URLS.NODE_URL(node.id)}>
|
||||||
|
<div className={classNames(styles.thumb, { [styles.new]: has_new })}>
|
||||||
|
<NodeRelatedItem item={node} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.title}>{node.title}</div>
|
||||||
|
<div className={styles.comment}>{getPrettyDate(node.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { FlowRecentItem };
|
57
src/components/flow/FlowRecentItem/styles.scss
Normal file
57
src/components/flow/FlowRecentItem/styles.scss
Normal file
|
@ -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;
|
||||||
|
}
|
52
src/components/flow/FlowSearchResults/index.tsx
Normal file
52
src/components/flow/FlowSearchResults/index.tsx
Normal file
|
@ -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<IProps> = ({ 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 (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<LoaderCircle size={64} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!search.results.length) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Icon size={96} icon="search" />
|
||||||
|
<div className={styles.nothing}>Ничего не найдено</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap} onScroll={onScroll}>
|
||||||
|
{search.results.map(node => (
|
||||||
|
<FlowRecentItem node={node} key={node.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { FlowSearchResults };
|
26
src/components/flow/FlowSearchResults/styles.scss
Normal file
26
src/components/flow/FlowSearchResults/styles.scss
Normal file
|
@ -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;
|
||||||
|
}
|
92
src/components/flow/FlowStamp/index.tsx
Normal file
92
src/components/flow/FlowStamp/index.tsx
Normal file
|
@ -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<IProps> = ({ 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 ? (
|
||||||
|
<Icon icon="close" size={24} className={styles.close_icon} onClick={onClearSearch} />
|
||||||
|
) : (
|
||||||
|
<Icon icon="search" size={24} className={styles.search_icon} />
|
||||||
|
),
|
||||||
|
[search.text]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<form className={styles.search} onSubmit={onSearchSubmit}>
|
||||||
|
<InputText
|
||||||
|
title="Поиск"
|
||||||
|
value={search.text}
|
||||||
|
handler={onSearchChange}
|
||||||
|
after={after}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{search.text ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<span className={styles.label_text}>Результаты поиска</span>
|
||||||
|
<span className="line" />
|
||||||
|
<span>{!search.is_loading && search.total}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.items}>
|
||||||
|
<FlowSearchResults search={search} onLoadMore={onLoadMore} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<span className={styles.label_text}>Что нового?</span>
|
||||||
|
<span className="line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.items}>
|
||||||
|
<FlowRecent updated={updated} recent={recent} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { FlowStamp };
|
99
src/components/flow/FlowStamp/styles.scss
Normal file
99
src/components/flow/FlowStamp/styles.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ const InputText: FC<IInputTextProps> = ({
|
||||||
value = '',
|
value = '',
|
||||||
onRef,
|
onRef,
|
||||||
is_loading,
|
is_loading,
|
||||||
|
after,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
|
@ -61,6 +62,7 @@ const InputText: FC<IInputTextProps> = ({
|
||||||
<div className={classNames(styles.success_icon, { active: status === 'success' })}>
|
<div className={classNames(styles.success_icon, { active: status === 'success' })}>
|
||||||
<Icon icon="check" size={20} />
|
<Icon icon="check" size={20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classNames(styles.error_icon, { active: status === 'error' || !!error })}>
|
<div className={classNames(styles.error_icon, { active: status === 'error' || !!error })}>
|
||||||
<Icon icon="close" size={20} />
|
<Icon icon="close" size={20} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -71,16 +73,20 @@ const InputText: FC<IInputTextProps> = ({
|
||||||
<LoaderCircle size={20} />
|
<LoaderCircle size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{title && (
|
{title && (
|
||||||
<div className={styles.title}>
|
<div className={classNames(styles.title, 'input_title')}>
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={styles.error}>
|
<div className={styles.error}>
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!!after && <div className={styles.after}>{after}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
13
src/components/main/Footer/index.tsx
Normal file
13
src/components/main/Footer/index.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import React, { FC, memo } from 'react';
|
||||||
|
import styles from './styles.scss';
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
const Footer: FC<IProps> = memo(() => (
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<div className={styles.slogan}>Уделяй больше времени тишине. Спасибо</div>
|
||||||
|
<div className={styles.copy}>2009 - {new Date().getFullYear()}</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
export { Footer };
|
23
src/components/main/Footer/styles.scss
Normal file
23
src/components/main/Footer/styles.scss
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -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 { connect } from 'react-redux';
|
||||||
import { push as historyPush } from 'connected-react-router';
|
import { push as historyPush } from 'connected-react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Logo } from '~/components/main/Logo';
|
import { Logo } from '~/components/main/Logo';
|
||||||
|
|
||||||
import { Filler } from '~/components/containers/Filler';
|
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 { Group } from '~/components/containers/Group';
|
||||||
import { DIALOGS } from '~/redux/modal/constants';
|
import { DIALOGS } from '~/redux/modal/constants';
|
||||||
import pick from 'ramda/es/pick';
|
import pick from 'ramda/es/pick';
|
||||||
|
@ -19,9 +19,12 @@ import classNames from 'classnames';
|
||||||
import * as style from './style.scss';
|
import * as style from './style.scss';
|
||||||
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
||||||
|
import { IState } from '~/redux/store';
|
||||||
|
import isBefore from 'date-fns/isBefore';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = (state: IState) => ({
|
||||||
user: pick(['username', 'is_user', 'photo'])(selectUser(state)),
|
user: pick(['username', 'is_user', 'photo', 'last_seen_boris'])(selectUser(state)),
|
||||||
|
updates: pick(['boris_commented_at'])(selectAuthUpdates(state)),
|
||||||
pathname: path(['router', 'location', 'pathname'], state),
|
pathname: path(['router', 'location', 'pathname'], state),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -35,7 +38,15 @@ const mapDispatchToProps = {
|
||||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
const HeaderUnconnected: FC<IProps> = memo(
|
const HeaderUnconnected: FC<IProps> = 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 [is_scrolled, setIsScrolled] = useState(false);
|
||||||
|
|
||||||
const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]);
|
const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]);
|
||||||
|
@ -55,6 +66,14 @@ const HeaderUnconnected: FC<IProps> = memo(
|
||||||
return () => window.removeEventListener('scroll', onScroll);
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
}, [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(
|
return createPortal(
|
||||||
<div className={classNames(style.wrap, { [style.is_scrolled]: is_scrolled })}>
|
<div className={classNames(style.wrap, { [style.is_scrolled]: is_scrolled })}>
|
||||||
<div className={style.container}>
|
<div className={style.container}>
|
||||||
|
@ -71,7 +90,10 @@ const HeaderUnconnected: FC<IProps> = memo(
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
className={classNames(style.item, { [style.is_active]: pathname === URLS.BORIS })}
|
className={classNames(style.item, {
|
||||||
|
[style.is_active]: pathname === URLS.BORIS,
|
||||||
|
[style.has_dot]: hasBorisUpdates,
|
||||||
|
})}
|
||||||
to={URLS.BORIS}
|
to={URLS.BORIS}
|
||||||
>
|
>
|
||||||
БОРИС
|
БОРИС
|
||||||
|
|
|
@ -90,11 +90,30 @@
|
||||||
transition: transform 0.5s, opacity 0.25s;
|
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 {
|
@include tablet {
|
||||||
padding: $gap;
|
padding: $gap;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
margin-left: $gap;
|
right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,7 +73,7 @@
|
||||||
top: 0;
|
top: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: all 0.5s;
|
transition: all 0.5s;
|
||||||
font: $font_16_medium;
|
font: $font_18_semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
|
||||||
import { INodeState } from '~/redux/node/reducer';
|
import { INodeState } from '~/redux/node/reducer';
|
||||||
import { CommentForm } from '../CommentForm';
|
import { CommentForm } from '../CommentForm';
|
||||||
import { CommendDeleted } from '../CommendDeleted';
|
import { CommendDeleted } from '../CommendDeleted';
|
||||||
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
is_empty?: boolean;
|
is_empty?: boolean;
|
||||||
|
@ -17,6 +18,7 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
can_edit?: boolean;
|
can_edit?: boolean;
|
||||||
onDelete: typeof nodeLockComment;
|
onDelete: typeof nodeLockComment;
|
||||||
onEdit: typeof nodeEditComment;
|
onEdit: typeof nodeEditComment;
|
||||||
|
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Comment: FC<IProps> = memo(
|
const Comment: FC<IProps> = memo(
|
||||||
|
@ -30,6 +32,7 @@ const Comment: FC<IProps> = memo(
|
||||||
can_edit,
|
can_edit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
modalShowPhotoswipe,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
|
@ -58,6 +61,7 @@ const Comment: FC<IProps> = memo(
|
||||||
can_edit={can_edit}
|
can_edit={can_edit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
|
modalShowPhotoswipe={modalShowPhotoswipe}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -12,18 +12,20 @@ import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { PRESETS } from '~/constants/urls';
|
import { PRESETS } from '~/constants/urls';
|
||||||
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
|
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
|
||||||
import { Icon } from '~/components/input/Icon';
|
|
||||||
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
|
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
|
||||||
import { CommentMenu } from '../CommentMenu';
|
import { CommentMenu } from '../CommentMenu';
|
||||||
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
can_edit: boolean;
|
can_edit: boolean;
|
||||||
onDelete: typeof nodeLockComment;
|
onDelete: typeof nodeLockComment;
|
||||||
onEdit: typeof nodeEditComment;
|
onEdit: typeof nodeEditComment;
|
||||||
|
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onEdit }) => {
|
const CommentContent: FC<IProps> = memo(
|
||||||
|
({ comment, can_edit, onDelete, onEdit, modalShowPhotoswipe }) => {
|
||||||
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||||
() =>
|
() =>
|
||||||
reduce(
|
reduce(
|
||||||
|
@ -53,11 +55,13 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onEdit }
|
||||||
<Group className={classnames(styles.block, styles.block_text)}>
|
<Group className={classnames(styles.block, styles.block_text)}>
|
||||||
{menu}
|
{menu}
|
||||||
|
|
||||||
|
<Group className={styles.renderers}>
|
||||||
{formatCommentText(path(['user', 'username'], comment), comment.text).map(
|
{formatCommentText(path(['user', 'username'], comment), comment.text).map(
|
||||||
(block, key) =>
|
(block, key) =>
|
||||||
COMMENT_BLOCK_RENDERERS[block.type] &&
|
COMMENT_BLOCK_RENDERERS[block.type] &&
|
||||||
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
|
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
|
||||||
)}
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
@ -68,9 +72,9 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onEdit }
|
||||||
{menu}
|
{menu}
|
||||||
|
|
||||||
<div className={styles.images}>
|
<div className={styles.images}>
|
||||||
{groupped.image.map(file => (
|
{groupped.image.map((file, index) => (
|
||||||
<div key={file.id}>
|
<div key={file.id} onClick={() => modalShowPhotoswipe(groupped.image, index)}>
|
||||||
<img src={getURL(file, PRESETS['300'])} alt={file.name} />
|
<img src={getURL(file, PRESETS['600'])} alt={file.name} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -94,17 +98,7 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onEdit }
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export { CommentContent };
|
export { CommentContent };
|
||||||
|
|
||||||
/*
|
|
||||||
{comment.text && (
|
|
||||||
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
|
@ -111,8 +111,13 @@
|
||||||
right: 0;
|
right: 0;
|
||||||
font: $font_12_regular;
|
font: $font_12_regular;
|
||||||
color: transparentize($color: white, $amount: 0.8);
|
color: transparentize($color: white, $amount: 0.8);
|
||||||
padding: 4px 6px 4px 6px;
|
padding: 0 6px 2px;
|
||||||
border-radius: 0 0 $radius 0;
|
border-radius: 0 0 $radius 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: $comment_bg;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.images {
|
.images {
|
||||||
|
@ -133,3 +138,8 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.renderers {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
|
@ -159,7 +159,11 @@ const CommentFormUnconnected: FC<IProps> = memo(
|
||||||
(fileId: IFile['id']) => {
|
(fileId: IFile['id']) => {
|
||||||
nodeSetCommentData(
|
nodeSetCommentData(
|
||||||
id,
|
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]
|
[comment_data, id, nodeSetCommentData]
|
||||||
|
@ -189,13 +193,17 @@ const CommentFormUnconnected: FC<IProps> = memo(
|
||||||
['files'],
|
['files'],
|
||||||
[
|
[
|
||||||
...audios,
|
...audios,
|
||||||
...(moveArrItem(oldIndex, newIndex, images.filter(file => !!file)) as IFile[]),
|
...(moveArrItem(
|
||||||
|
oldIndex,
|
||||||
|
newIndex,
|
||||||
|
images.filter(file => !!file)
|
||||||
|
) as IFile[]),
|
||||||
],
|
],
|
||||||
comment_data[id]
|
comment_data[id]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[images, audios]
|
[images, audios, comment_data, nodeSetCommentData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onAudioMove = useCallback(
|
const onAudioMove = useCallback(
|
||||||
|
@ -206,13 +214,17 @@ const CommentFormUnconnected: FC<IProps> = memo(
|
||||||
['files'],
|
['files'],
|
||||||
[
|
[
|
||||||
...images,
|
...images,
|
||||||
...(moveArrItem(oldIndex, newIndex, audios.filter(file => !!file)) as IFile[]),
|
...(moveArrItem(
|
||||||
|
oldIndex,
|
||||||
|
newIndex,
|
||||||
|
audios.filter(file => !!file)
|
||||||
|
) as IFile[]),
|
||||||
],
|
],
|
||||||
comment_data[id]
|
comment_data[id]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[images, audios]
|
[images, audios, comment_data, nodeSetCommentData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCancelEdit = useCallback(() => {
|
const onCancelEdit = useCallback(() => {
|
||||||
|
@ -299,9 +311,6 @@ const CommentFormUnconnected: FC<IProps> = memo(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const CommentForm = connect(
|
const CommentForm = connect(mapStateToProps, mapDispatchToProps)(CommentFormUnconnected);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(CommentFormUnconnected);
|
|
||||||
|
|
||||||
export { CommentForm, CommentFormUnconnected };
|
export { CommentForm, CommentFormUnconnected };
|
||||||
|
|
|
@ -2,23 +2,22 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0;
|
height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switcher {
|
.switcher {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: transparentize(black, 0.5);
|
// background: darken($content_bg, 2%);
|
||||||
|
background: url('../../../../src/sprites/noise.png') $main_bg_color;
|
||||||
display: flex;
|
display: flex;
|
||||||
right: $gap;
|
left: 50%;
|
||||||
top: $gap;
|
transform: translate(-50%, 0);
|
||||||
|
top: -60px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
flex-wrap: wrap;
|
// flex-wrap: wrap;
|
||||||
transition: background-color 0.5s;
|
transition: background-color 0.5s;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
&:hover {
|
|
||||||
background: transparentize(black, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
|
@ -28,19 +27,14 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity 0.25s;
|
transition: opacity 0.25s;
|
||||||
|
opacity: 0.5;
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
display: block;
|
display: block;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
// background: white;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: inset white 0 0 0 2px;
|
box-shadow: inset white 0 0 0 2px;
|
||||||
transform: scale(0.5);
|
transform: scale(0.5);
|
||||||
|
|
|
@ -3,10 +3,9 @@ import { INode } from '~/redux/types';
|
||||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
import { INodeComponentProps } from '~/redux/node/constants';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends INodeComponentProps {}
|
||||||
node: INode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodeAudioBlock: FC<IProps> = ({ node }) => {
|
const NodeAudioBlock: FC<IProps> = ({ node }) => {
|
||||||
const audios = useMemo(
|
const audios = useMemo(
|
||||||
|
|
|
@ -5,10 +5,9 @@ import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
import path from 'ramda/es/path';
|
import path from 'ramda/es/path';
|
||||||
import { getURL } from '~/utils/dom';
|
import { getURL } from '~/utils/dom';
|
||||||
import { PRESETS } from '~/constants/urls';
|
import { PRESETS } from '~/constants/urls';
|
||||||
|
import { INodeComponentProps } from '~/redux/node/constants';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends INodeComponentProps {}
|
||||||
node: INode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
|
const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
|
||||||
const images = useMemo(
|
const images = useMemo(
|
||||||
|
|
|
@ -7,24 +7,67 @@ import { ICommentGroup, IComment } from '~/redux/types';
|
||||||
import { groupCommentsByUser } from '~/utils/fn';
|
import { groupCommentsByUser } from '~/utils/fn';
|
||||||
import { IUser } from '~/redux/auth/types';
|
import { IUser } from '~/redux/auth/types';
|
||||||
import { canEditComment } from '~/utils/node';
|
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 { 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 {
|
interface IProps {
|
||||||
comments?: IComment[];
|
comments?: IComment[];
|
||||||
comment_data: INodeState['comment_data'];
|
comment_data: INodeState['comment_data'];
|
||||||
|
comment_count: INodeState['comment_count'];
|
||||||
user: IUser;
|
user: IUser;
|
||||||
onDelete: typeof nodeLockComment;
|
onDelete: typeof nodeLockComment;
|
||||||
onEdit: typeof nodeEditComment;
|
onEdit: typeof nodeEditComment;
|
||||||
|
onLoadMore: typeof nodeLoadMoreComments;
|
||||||
|
order?: 'ASC' | 'DESC';
|
||||||
|
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeComments: FC<IProps> = memo(({ comments, comment_data, user, onDelete, onEdit }) => {
|
const NodeComments: FC<IProps> = memo(
|
||||||
const groupped: ICommentGroup[] = useMemo(() => comments.reduce(groupCommentsByUser, []), [
|
({
|
||||||
comments,
|
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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const groupped: ICommentGroup[] = useMemo(
|
||||||
|
() => (order === 'DESC' ? [...comments].reverse() : comments).reduce(groupCommentsByUser, []),
|
||||||
|
[comments, order]
|
||||||
|
);
|
||||||
|
|
||||||
|
const more = useMemo(
|
||||||
|
() =>
|
||||||
|
comments_left > 0 && (
|
||||||
|
<div className={styles.more} onClick={onLoadMore}>
|
||||||
|
Показать ещё{' '}
|
||||||
|
{plural(
|
||||||
|
Math.min(comments_left, COMMENTS_DISPLAY),
|
||||||
|
'комментарий',
|
||||||
|
'комментария',
|
||||||
|
'комментариев'
|
||||||
|
)}
|
||||||
|
{comments_left > COMMENTS_DISPLAY ? ` из ${comments_left} оставшихся` : ''}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[comments_left, onLoadMore, COMMENTS_DISPLAY]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
|
{order === 'DESC' && more}
|
||||||
|
|
||||||
{groupped.map(group => (
|
{groupped.map(group => (
|
||||||
<Comment
|
<Comment
|
||||||
key={group.ids.join()}
|
key={group.ids.join()}
|
||||||
|
@ -33,10 +76,14 @@ const NodeComments: FC<IProps> = memo(({ comments, comment_data, user, onDelete,
|
||||||
can_edit={canEditComment(group, user)}
|
can_edit={canEditComment(group, user)}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
|
modalShowPhotoswipe={modalShowPhotoswipe}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{order === 'ASC' && more}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export { NodeComments };
|
export { NodeComments };
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -1,94 +1,85 @@
|
||||||
import React, {
|
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
FC,
|
import { ImageSwitcher } from '../ImageSwitcher';
|
||||||
useMemo,
|
import * as styles from './styles.scss';
|
||||||
useState,
|
import { INode } from '~/redux/types';
|
||||||
useEffect,
|
import classNames from 'classnames';
|
||||||
useRef,
|
import { getURL } from '~/utils/dom';
|
||||||
useCallback
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
} from "react";
|
import { PRESETS } from '~/constants/urls';
|
||||||
import { ImageSwitcher } from "../ImageSwitcher";
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
import * as styles from "./styles.scss";
|
import { LoaderCircle } from '~/components/input/LoaderCircle';
|
||||||
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";
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
is_loading: boolean;
|
is_loading: boolean;
|
||||||
node: INode;
|
node: INode;
|
||||||
layout: {};
|
layout: {};
|
||||||
updateLayout: () => void;
|
updateLayout: () => void;
|
||||||
|
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeImageBlock: FC<IProps> = ({ node, is_loading, updateLayout }) => {
|
const NodeImageBlock: FC<IProps> = ({ node, is_loading, updateLayout, modalShowPhotoswipe }) => {
|
||||||
const [is_animated, setIsAnimated] = useState(false);
|
const [is_animated, setIsAnimated] = useState(false);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [height, setHeight] = useState(320);
|
const [height, setHeight] = useState(window.innerHeight - 150);
|
||||||
const [loaded, setLoaded] = useState<Record<number, boolean>>({});
|
const [loaded, setLoaded] = useState<Record<number, boolean>>({});
|
||||||
const refs = useRef<Record<number, HTMLDivElement>>({});
|
const refs = useRef<Record<number, HTMLDivElement>>({});
|
||||||
|
|
||||||
const images = useMemo(
|
const images = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(node &&
|
(node && node.files && node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || [],
|
||||||
node.files &&
|
|
||||||
node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) ||
|
|
||||||
[],
|
|
||||||
[node]
|
[node]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setRef = useCallback(index => el => (refs.current[index] = el), [refs]);
|
const setRef = useCallback(index => el => (refs.current[index] = el), [refs]);
|
||||||
const onImageLoad = useCallback(
|
|
||||||
index => () => setLoaded({ ...loaded, [index]: true }),
|
const onImageLoad = useCallback(index => () => setLoaded({ ...loaded, [index]: true }), [
|
||||||
[setLoaded, loaded]
|
setLoaded,
|
||||||
);
|
loaded,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => updateLayout(), [loaded]);
|
useEffect(() => updateLayout(), [loaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!refs || !refs.current[current] || !loaded[current])
|
if (!refs || !refs.current[current] || !loaded[current])
|
||||||
return setHeight(320);
|
return setHeight(window.innerHeight - 150);
|
||||||
|
|
||||||
const el = refs.current[current];
|
const el = refs.current[current];
|
||||||
|
const element_height = el.getBoundingClientRect && el.getBoundingClientRect().height;
|
||||||
const element_height =
|
|
||||||
el.getBoundingClientRect && el.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
setHeight(element_height);
|
setHeight(element_height);
|
||||||
}, [refs, current, loaded]);
|
}, [refs, current, loaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setIsAnimated(true), 250);
|
const timer = setTimeout(() => setIsAnimated(true), 250);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onOpenPhotoSwipe = useCallback(() => modalShowPhotoswipe(images, current), [
|
||||||
|
modalShowPhotoswipe,
|
||||||
|
images,
|
||||||
|
current,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.wrap, { is_loading, is_animated })}>
|
<div className={classNames(styles.wrap, { is_loading, is_animated })}>
|
||||||
<div>
|
<div className={styles.image_container} style={{ height }} onClick={onOpenPhotoSwipe}>
|
||||||
<ImageSwitcher
|
|
||||||
total={images.length}
|
|
||||||
current={current}
|
|
||||||
onChange={setCurrent}
|
|
||||||
loaded={loaded}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.image_container} style={{ height }}>
|
|
||||||
{(is_loading || !loaded[0] || !images.length) && (
|
{(is_loading || !loaded[0] || !images.length) && (
|
||||||
<div className={styles.placeholder} />
|
<div className={styles.placeholder}>
|
||||||
|
<LoaderCircle size={72} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{images.map((file, index) => (
|
{images.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.image_wrap, {
|
className={classNames(styles.image_wrap, {
|
||||||
is_active: index === current && loaded[index]
|
is_active: index === current && loaded[index],
|
||||||
})}
|
})}
|
||||||
ref={setRef(index)}
|
ref={setRef(index)}
|
||||||
key={file.id}
|
key={file.id}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
src={getURL(file, PRESETS["1600"])}
|
src={getURL(file, PRESETS['1600'])}
|
||||||
alt=""
|
alt=""
|
||||||
key={file.id}
|
key={file.id}
|
||||||
onLoad={onImageLoad(index)}
|
onLoad={onImageLoad(index)}
|
||||||
|
@ -96,7 +87,13 @@ const NodeImageBlock: FC<IProps> = ({ node, is_loading, updateLayout }) => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<ImageSwitcher
|
||||||
|
total={images.length}
|
||||||
|
current={current}
|
||||||
|
onChange={setCurrent}
|
||||||
|
loaded={loaded}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
|
padding-bottom: $gap * 2;
|
||||||
|
|
||||||
&:global(.is_animated) {
|
&:global(.is_animated) {
|
||||||
.image_container {
|
.image_container {
|
||||||
transition: height 0.5s;
|
transition: height 0.5s;
|
||||||
|
@ -12,7 +14,6 @@
|
||||||
|
|
||||||
.image_container {
|
.image_container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: $node_image_bg;
|
|
||||||
border-radius: $panel_radius 0 0 $panel_radius;
|
border-radius: $panel_radius 0 0 $panel_radius;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -22,10 +23,12 @@
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
max-height: 960px;
|
max-height: calc(100vh - 150px);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-radius: $radius $radius 0 0;
|
border-radius: $radius;
|
||||||
|
|
||||||
|
@include outer_shadow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,6 +51,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
background: red;
|
width: 100%;
|
||||||
height: 320px;
|
height: calc(100vh - 130px);
|
||||||
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +1,28 @@
|
||||||
import React, {
|
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
FC,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useCallback,
|
|
||||||
useLayoutEffect,
|
|
||||||
} from 'react';
|
|
||||||
import { ImageSwitcher } from '../ImageSwitcher';
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { INode } from '~/redux/types';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
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 { getURL } from '~/utils/dom';
|
||||||
import { PRESETS } from '~/constants/urls';
|
import { PRESETS } from '~/constants/urls';
|
||||||
import { LoaderCircle } from '~/components/input/LoaderCircle';
|
import { LoaderCircle } from '~/components/input/LoaderCircle';
|
||||||
import { throttle } from 'throttle-debounce';
|
import { throttle } from 'throttle-debounce';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends INodeComponentProps {}
|
||||||
is_loading: boolean;
|
|
||||||
node: INode;
|
|
||||||
layout: {};
|
|
||||||
updateLayout: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<IProps> = ({ node, is_loading, updateLayout }) => {
|
const NodeImageSlideBlock: FC<IProps> = ({
|
||||||
|
node,
|
||||||
|
is_loading,
|
||||||
|
is_modal_shown,
|
||||||
|
updateLayout,
|
||||||
|
modalShowPhotoswipe,
|
||||||
|
}) => {
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [height, setHeight] = useState(320);
|
const [height, setHeight] = useState(320);
|
||||||
const [max_height, setMaxHeight] = useState(960);
|
const [max_height, setMaxHeight] = useState(960);
|
||||||
|
@ -39,6 +34,8 @@ const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) =>
|
||||||
const [initial_x, setInitialX] = useState(0);
|
const [initial_x, setInitialX] = useState(0);
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [is_dragging, setIsDragging] = useState(false);
|
const [is_dragging, setIsDragging] = useState(false);
|
||||||
|
const [drag_start, setDragStart] = useState(0);
|
||||||
|
|
||||||
const slide = useRef<HTMLDivElement>();
|
const slide = useRef<HTMLDivElement>();
|
||||||
const wrap = useRef<HTMLDivElement>();
|
const wrap = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
@ -162,24 +159,41 @@ const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) =>
|
||||||
const updateMaxHeight = useCallback(() => {
|
const updateMaxHeight = useCallback(() => {
|
||||||
if (!wrap.current) return;
|
if (!wrap.current) return;
|
||||||
const { width } = wrap.current.getBoundingClientRect();
|
const { width } = wrap.current.getBoundingClientRect();
|
||||||
setMaxHeight(width * NODE_SETTINGS.MAX_IMAGE_ASPECT);
|
setMaxHeight(window.innerHeight - 143);
|
||||||
normalizeOffset();
|
normalizeOffset();
|
||||||
}, [wrap, setMaxHeight, normalizeOffset]);
|
}, [wrap, setMaxHeight, normalizeOffset]);
|
||||||
|
|
||||||
const stopDragging = useCallback(() => {
|
const onOpenPhotoSwipe = useCallback(() => modalShowPhotoswipe(images, current), [
|
||||||
|
modalShowPhotoswipe,
|
||||||
|
images,
|
||||||
|
current,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stopDragging = useCallback(
|
||||||
|
event => {
|
||||||
if (!is_dragging) return;
|
if (!is_dragging) return;
|
||||||
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
normalizeOffset();
|
normalizeOffset();
|
||||||
}, [setIsDragging, is_dragging, 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(
|
const startDragging = useCallback(
|
||||||
event => {
|
event => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setInitialX(getX(event));
|
setInitialX(getX(event));
|
||||||
setInitialOffset(offset);
|
setInitialOffset(offset);
|
||||||
|
setDragStart(new Date().getTime());
|
||||||
},
|
},
|
||||||
[setIsDragging, setInitialX, offset, setInitialOffset]
|
[setIsDragging, setInitialX, offset, setInitialOffset, setDragStart]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => updateMaxHeight(), [images]);
|
useEffect(() => updateMaxHeight(), [images]);
|
||||||
|
@ -214,8 +228,49 @@ const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) =>
|
||||||
[wrap]
|
[wrap]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onPrev = useCallback(() => changeCurrent(current > 0 ? current - 1 : images.length - 1), [
|
||||||
|
changeCurrent,
|
||||||
|
current,
|
||||||
|
images,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={classNames(styles.wrap, { [styles.is_loading]: is_loading })} ref={wrap}>
|
<div className={styles.wrap}>
|
||||||
|
<div className={classNames(styles.cutter, { [styles.is_loading]: is_loading })} ref={wrap}>
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.placeholder, {
|
className={classNames(styles.placeholder, {
|
||||||
[styles.is_loading]: is_loading || !loaded[current],
|
[styles.is_loading]: is_loading || !loaded[current],
|
||||||
|
@ -226,15 +281,6 @@ const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) =>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!is_loading && (
|
|
||||||
<ImageSwitcher
|
|
||||||
total={images.length}
|
|
||||||
current={current}
|
|
||||||
onChange={changeCurrent}
|
|
||||||
loaded={loaded}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.image_container, { [styles.is_dragging]: is_dragging })}
|
className={classNames(styles.image_container, { [styles.is_dragging]: is_dragging })}
|
||||||
style={{
|
style={{
|
||||||
|
@ -266,6 +312,38 @@ const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) =>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className={styles.image_count}>
|
||||||
|
{current + 1}
|
||||||
|
<small> / </small>
|
||||||
|
{images.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
!is_loading && (
|
||||||
|
<ImageSwitcher
|
||||||
|
total={images.length}
|
||||||
|
current={current}
|
||||||
|
onChange={changeCurrent}
|
||||||
|
loaded={loaded}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className={classNames(styles.image_arrow)} onClick={onPrev}>
|
||||||
|
<Icon icon="left" size={40} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className={classNames(styles.image_arrow, styles.image_arrow_right)} onClick={onNext}>
|
||||||
|
<Icon icon="right" size={40} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,21 +1,30 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutter {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
|
||||||
transition: height 0.25s;
|
transition: height 0.25s;
|
||||||
border-radius: $radius $radius 0 0;
|
border-radius: $radius;
|
||||||
|
margin-right: -$gap / 2;
|
||||||
|
margin-left: -$gap / 2;
|
||||||
|
|
||||||
.is_loading {
|
.is_loading {
|
||||||
.placeholder {
|
.placeholder {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image_container {
|
.image_container {
|
||||||
// background: $node_image_bg;
|
|
||||||
border-radius: $panel_radius 0 0 $panel_radius;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
@ -24,17 +33,22 @@
|
||||||
user-select: none;
|
user-select: none;
|
||||||
will-change: transform, height;
|
will-change: transform, height;
|
||||||
transition: height 500ms, transform 500ms;
|
transition: height 500ms, transform 500ms;
|
||||||
|
padding: 0 0 20px 0;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
// max-height: 960px;
|
|
||||||
max-height: 120vh !important;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
opacity: 1;
|
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 {
|
&.is_dragging {
|
||||||
|
@ -44,19 +58,91 @@
|
||||||
|
|
||||||
.image_wrap {
|
.image_wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
// top: 0;
|
|
||||||
// left: 0;
|
|
||||||
// opacity: 0;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 0 $gap / 2;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:global(.is_active) {
|
&:global(.is_active) {
|
||||||
opacity: 1;
|
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 {
|
.placeholder {
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
&::after {
|
&::after {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
position: absolute;
|
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;
|
border-radius: 0 0 $radius $radius;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -47,7 +47,8 @@ const NodePanel: FC<IProps> = memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.place} ref={ref}>
|
<div className={styles.place} ref={ref}>
|
||||||
{stack &&
|
{/*
|
||||||
|
stack &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<NodePanelInner
|
<NodePanelInner
|
||||||
node={node}
|
node={node}
|
||||||
|
@ -62,7 +63,8 @@ const NodePanel: FC<IProps> = memo(
|
||||||
stack
|
stack
|
||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)
|
||||||
|
*/}
|
||||||
|
|
||||||
<NodePanelInner
|
<NodePanelInner
|
||||||
node={node}
|
node={node}
|
||||||
|
|
|
@ -26,7 +26,7 @@ interface IProps {
|
||||||
|
|
||||||
const NodePanelInner: FC<IProps> = memo(
|
const NodePanelInner: FC<IProps> = 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,
|
stack,
|
||||||
|
|
||||||
can_star,
|
can_star,
|
||||||
|
@ -43,24 +43,29 @@ const NodePanelInner: FC<IProps> = memo(
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.wrap, { stack })}>
|
<div className={classNames(styles.wrap, { stack })}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Group horizontal className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<Filler>
|
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
{is_loading ? <Placeholder width="40%" /> : title || '...'}
|
{is_loading ? <Placeholder width="40%" /> : title || '...'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && user.username && (
|
{user && user.username && (
|
||||||
<div className={styles.name}>
|
<div className={styles.name}>
|
||||||
{is_loading ? (
|
{is_loading ? (
|
||||||
<Placeholder width="100px" />
|
<Placeholder width="100px" />
|
||||||
) : (
|
) : (
|
||||||
`~${user.username}, ${getPrettyDate(created_at)}`
|
`~${user.username.toLocaleLowerCase()}, ${getPrettyDate(created_at)}`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Filler>
|
</div>
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div className={styles.buttons}>
|
{can_edit && (
|
||||||
|
<div className={styles.editor_menu}>
|
||||||
|
<div className={styles.editor_menu_button}>
|
||||||
|
<Icon icon="dots-vertical" size={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.editor_buttons}>
|
||||||
{can_star && (
|
{can_star && (
|
||||||
<div className={classNames(styles.star, { is_heroic })}>
|
<div className={classNames(styles.star, { is_heroic })}>
|
||||||
{is_heroic ? (
|
{is_heroic ? (
|
||||||
|
@ -71,8 +76,6 @@ const NodePanelInner: FC<IProps> = memo(
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{can_edit && (
|
|
||||||
<>
|
|
||||||
<div>
|
<div>
|
||||||
<Icon icon={deleted_at ? 'locked' : 'unlocked'} size={24} onClick={onLock} />
|
<Icon icon={deleted_at ? 'locked' : 'unlocked'} size={24} onClick={onLock} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -80,9 +83,11 @@ const NodePanelInner: FC<IProps> = memo(
|
||||||
<div>
|
<div>
|
||||||
<Icon icon="edit" size={24} onClick={onEdit} />
|
<Icon icon="edit" size={24} onClick={onEdit} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={styles.buttons}>
|
||||||
{can_like && (
|
{can_like && (
|
||||||
<div className={classNames(styles.like, { is_liked })}>
|
<div className={classNames(styles.like, { is_liked })}>
|
||||||
{is_liked ? (
|
{is_liked ? (
|
||||||
|
@ -90,6 +95,8 @@ const NodePanelInner: FC<IProps> = memo(
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="heart" size={24} onClick={onLike} />
|
<Icon icon="heart" size={24} onClick={onLike} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{like_count > 0 && <div className={styles.like_count}>{like_count}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,92 +1,4 @@
|
||||||
.wrap {
|
@mixin button {
|
||||||
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;
|
|
||||||
|
|
||||||
&:global(.stack) {
|
|
||||||
padding: 0 $gap;
|
|
||||||
bottom: 0;
|
|
||||||
position: fixed;
|
|
||||||
|
|
||||||
@include tablet {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 0 1 $content_width;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: stretch;
|
|
||||||
border-radius: $radius $radius 0 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: $gap;
|
|
||||||
background: $node_bg;
|
|
||||||
|
|
||||||
@include outer_shadow();
|
|
||||||
|
|
||||||
@include tablet {
|
|
||||||
border-radius: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include can_backdrop {
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
background: transparentize($node_bg, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font: $font_24_semibold;
|
|
||||||
// height: 24px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
|
|
||||||
@include tablet {
|
|
||||||
// font-size: 16px;
|
|
||||||
word-break: break-word;
|
|
||||||
padding-bottom: 0;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font: $font_14_regular;
|
|
||||||
color: transparentize(white, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
fill: transparentize(white, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
flex: 0;
|
|
||||||
padding-right: $gap;
|
|
||||||
fill: transparentize(white, 0.7);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
margin: 0 $gap;
|
margin: 0 $gap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -113,22 +25,143 @@
|
||||||
background: transparentize(black, 0.7);
|
background: transparentize(black, 0.7);
|
||||||
margin-left: $gap * 2;
|
margin-left: $gap * 2;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:first-child {
|
.wrap {
|
||||||
margin-left: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
position: relative;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 0 1 $content_width;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: $gap $gap;
|
||||||
|
background: $node_bg;
|
||||||
|
height: 64px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
border-radius: 0;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include can_backdrop {
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
background: transparentize($node_bg, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font: $font_24_semibold;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-bottom: 0;
|
||||||
|
font: $font_20_semibold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font: $font_14_regular;
|
||||||
|
color: transparentize(white, 0.5);
|
||||||
|
text-transform: lowercase;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
font: $font_12_regular;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
fill: transparentize(white, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons,
|
||||||
|
.editor_buttons {
|
||||||
|
flex: 0;
|
||||||
|
padding-right: $gap;
|
||||||
|
fill: transparentize(white, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
@include button;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
& > * {
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor_buttons {
|
||||||
@include tablet {
|
@include tablet {
|
||||||
margin-top: $gap * 2;
|
display: none;
|
||||||
align-self: center;
|
|
||||||
|
& > * {
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,17 +214,42 @@
|
||||||
.like {
|
.like {
|
||||||
transition: fill, stroke 0.25s;
|
transition: fill, stroke 0.25s;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:global(.is_liked) {
|
&:global(.is_liked) {
|
||||||
svg {
|
svg {
|
||||||
fill: $red;
|
fill: $red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.like_count {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
fill: $red;
|
fill: $red;
|
||||||
animation: pulse 0.75s infinite;
|
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 {
|
.star {
|
||||||
|
@ -208,3 +266,31 @@
|
||||||
fill: $orange;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,10 +13,9 @@ const NodeRelated: FC<IProps> = ({ title, items }) => {
|
||||||
return (
|
return (
|
||||||
<Group className={styles.wrap}>
|
<Group className={styles.wrap}>
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<div className={styles.line} />
|
|
||||||
<div className={styles.text}>{title}</div>
|
<div className={styles.text}>{title}</div>
|
||||||
<div className={styles.line} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NodeRelatedItem item={item} key={item.id} />
|
<NodeRelatedItem item={item} key={item.id} />
|
||||||
|
|
|
@ -17,24 +17,13 @@
|
||||||
grid-template-columns: repeat(6, 1fr);
|
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 {
|
.title {
|
||||||
display: none;
|
@include title_with_line();
|
||||||
flex: 1;
|
|
||||||
height: 2px;
|
|
||||||
background: transparentize(white, 0.95);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
margin: 0 $gap;
|
margin-left: $gap / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
|
|
|
@ -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 * as styles from './styles.scss';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { URLS, PRESETS } from '~/constants/urls';
|
import { URLS, PRESETS } from '~/constants/urls';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import { getURL } from '~/utils/dom';
|
import { getURL, stringToColour } from '~/utils/dom';
|
||||||
|
|
||||||
type IProps = RouteComponentProps & {
|
type IProps = RouteComponentProps & {
|
||||||
item: Partial<INode>;
|
item: Partial<INode>;
|
||||||
|
@ -28,6 +28,15 @@ const NodeRelatedItemUnconnected: FC<IProps> = memo(({ item, history }) => {
|
||||||
const [is_loaded, setIsLoaded] = useState(false);
|
const [is_loaded, setIsLoaded] = useState(false);
|
||||||
const onClick = useCallback(() => history.push(URLS.NODE_URL(item.id)), [item, history]);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.item, { [styles.is_loaded]: is_loaded })}
|
className={classNames(styles.item, { [styles.is_loaded]: is_loaded })}
|
||||||
|
@ -36,10 +45,16 @@ const NodeRelatedItemUnconnected: FC<IProps> = memo(({ item, history }) => {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={styles.thumb}
|
className={styles.thumb}
|
||||||
style={{ backgroundImage: `url("${getURL({ url: item.thumbnail }, PRESETS.avatar)}")` }}
|
style={{
|
||||||
|
backgroundImage: `url("${thumb}")`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!item.thumbnail && <div className={styles.letters}>{getTitleLetters(item.title)}</div>}
|
{!item.thumbnail && (
|
||||||
|
<div className={styles.letters} style={{ backgroundColor }}>
|
||||||
|
{getTitleLetters(item.title)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={getURL({ url: item.thumbnail }, PRESETS.avatar)}
|
src={getURL({ url: item.thumbnail }, PRESETS.avatar)}
|
||||||
|
|
|
@ -40,6 +40,11 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font: $font_24_semibold;
|
font: $font_24_semibold;
|
||||||
// opacity: 0.2;
|
color: transparentize(white, 0.5);
|
||||||
color: #444444;
|
border-radius: $cell_radius;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparentize($content_bg, 0.4),
|
||||||
|
transparentize($content_bg, 0.4)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,9 @@ import { INode } from '~/redux/types';
|
||||||
import path from 'ramda/es/path';
|
import path from 'ramda/es/path';
|
||||||
import { formatTextParagraphs } from '~/utils/dom';
|
import { formatTextParagraphs } from '~/utils/dom';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
import { INodeComponentProps } from '~/redux/node/constants';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends INodeComponentProps {}
|
||||||
node: INode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodeTextBlock: FC<IProps> = ({ node }) => (
|
const NodeTextBlock: FC<IProps> = ({ node }) => (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import React, { FC, useMemo } from 'react';
|
import React, { FC, useMemo } from 'react';
|
||||||
import { INode } from '~/redux/types';
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import path from 'ramda/es/path';
|
import path from 'ramda/es/path';
|
||||||
|
import { INodeComponentProps } from '~/redux/node/constants';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends INodeComponentProps {}
|
||||||
node: INode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodeVideoBlock: FC<IProps> = ({ node }) => {
|
const NodeVideoBlock: FC<IProps> = ({ node }) => {
|
||||||
const video = useMemo(() => {
|
const video = useMemo(() => {
|
||||||
|
|
|
@ -30,4 +30,13 @@ export const API = {
|
||||||
`/node/${id}/comment/${comment_id}/lock`,
|
`/node/${id}/comment/${comment_id}/lock`,
|
||||||
SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`,
|
SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`,
|
||||||
},
|
},
|
||||||
|
SEARCH: {
|
||||||
|
NODES: '/search/nodes',
|
||||||
|
},
|
||||||
|
EMBED: {
|
||||||
|
YOUTUBE: '/embed/youtube',
|
||||||
|
},
|
||||||
|
BORIS: {
|
||||||
|
GET_BACKEND_STATS: '/stats',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -27,6 +27,10 @@ export type ICommentBlock = {
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ICommentBlockProps = {
|
||||||
|
block: ICommentBlock;
|
||||||
|
};
|
||||||
|
|
||||||
export const COMMENT_BLOCK_RENDERERS = {
|
export const COMMENT_BLOCK_RENDERERS = {
|
||||||
[COMMENT_BLOCK_TYPES.TEXT]: CommentTextBlock,
|
[COMMENT_BLOCK_TYPES.TEXT]: CommentTextBlock,
|
||||||
[COMMENT_BLOCK_TYPES.MARK]: CommentTextBlock,
|
[COMMENT_BLOCK_TYPES.MARK]: CommentTextBlock,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { ProfileDialog } from '~/containers/dialogs/ProfileDialog';
|
||||||
import { RestoreRequestDialog } from '~/containers/dialogs/RestoreRequestDialog';
|
import { RestoreRequestDialog } from '~/containers/dialogs/RestoreRequestDialog';
|
||||||
import { RestorePasswordDialog } from '~/containers/dialogs/RestorePasswordDialog';
|
import { RestorePasswordDialog } from '~/containers/dialogs/RestorePasswordDialog';
|
||||||
import { DIALOGS } from '~/redux/modal/constants';
|
import { DIALOGS } from '~/redux/modal/constants';
|
||||||
|
import { PhotoSwipe } from '~/containers/dialogs/PhotoSwipe';
|
||||||
|
|
||||||
export const DIALOG_CONTENT = {
|
export const DIALOG_CONTENT = {
|
||||||
[DIALOGS.EDITOR_IMAGE]: EditorDialogImage,
|
[DIALOGS.EDITOR_IMAGE]: EditorDialogImage,
|
||||||
|
@ -22,6 +23,7 @@ export const DIALOG_CONTENT = {
|
||||||
[DIALOGS.PROFILE]: ProfileDialog,
|
[DIALOGS.PROFILE]: ProfileDialog,
|
||||||
[DIALOGS.RESTORE_REQUEST]: RestoreRequestDialog,
|
[DIALOGS.RESTORE_REQUEST]: RestoreRequestDialog,
|
||||||
[DIALOGS.RESTORE_PASSWORD]: RestorePasswordDialog,
|
[DIALOGS.RESTORE_PASSWORD]: RestorePasswordDialog,
|
||||||
|
[DIALOGS.PHOTOSWIPE]: PhotoSwipe,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NODE_EDITOR_DIALOGS = {
|
export const NODE_EDITOR_DIALOGS = {
|
||||||
|
|
|
@ -18,8 +18,19 @@ export const PHRASES = {
|
||||||
'Роботы, несомненно, изредка видят сны об электроовцах. И не только.',
|
'Роботы, несомненно, изредка видят сны об электроовцах. И не только.',
|
||||||
'Постарайтесь забыть о хурме как можно скорее. Хурма пагубна и коварна.',
|
'Постарайтесь забыть о хурме как можно скорее. Хурма пагубна и коварна.',
|
||||||
'Возможно, именно сейчас вы спите, и всё происходящее - лишь глупый сон. Но подумайте, стоит ли щипать себя почём зря?',
|
'Возможно, именно сейчас вы спите, и всё происходящее - лишь глупый сон. Но подумайте, стоит ли щипать себя почём зря?',
|
||||||
|
'Фыфывдыфвдфывфыф ывфы фывфывфы ахахаха, о даааа!',
|
||||||
|
'Дид ай толд ю вэт ай лав ю? Ноу, рили?',
|
||||||
|
'У нас тут такое не только не приветствуется, но и всячески... Эй, это кабачок?',
|
||||||
|
],
|
||||||
|
BORIS_TITLE: [
|
||||||
|
'Снова вместе',
|
||||||
|
'Я видел это во сне',
|
||||||
|
'Что тут у нас?',
|
||||||
|
'Мы скучали, а ты?',
|
||||||
|
"Here's Boris!",
|
||||||
|
'Боброборцы - вперёд!',
|
||||||
|
'Супротив и вопреки',
|
||||||
],
|
],
|
||||||
BORIS_TITLE: ['Снова вместе', 'Я видел это во сне', 'Что тут у нас?'],
|
|
||||||
NOTHING_HERE: [
|
NOTHING_HERE: [
|
||||||
'Тут пусто и одиноко',
|
'Тут пусто и одиноко',
|
||||||
'Совсем ничего',
|
'Совсем ничего',
|
||||||
|
|
|
@ -14,7 +14,7 @@ export const URLS = {
|
||||||
},
|
},
|
||||||
NODE_URL: (id: number | string) => `/post${id}`,
|
NODE_URL: (id: number | string) => `/post${id}`,
|
||||||
PROFILE: (username: string) => `/~${username}`,
|
PROFILE: (username: string) => `/~${username}`,
|
||||||
PROFILE_PAGE: `/profile`,
|
PROFILE_PAGE: (username: string) => `/profile/${username}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PRESETS = {
|
export const PRESETS = {
|
||||||
|
|
|
@ -6,8 +6,6 @@ import { Switch, Route, Redirect } from 'react-router-dom';
|
||||||
import { history } from '~/redux/store';
|
import { history } from '~/redux/store';
|
||||||
import { FlowLayout } from '~/containers/flow/FlowLayout';
|
import { FlowLayout } from '~/containers/flow/FlowLayout';
|
||||||
import { MainLayout } from '~/containers/main/MainLayout';
|
import { MainLayout } from '~/containers/main/MainLayout';
|
||||||
import { ImageExample } from '~/containers/examples/ImageExample';
|
|
||||||
import { EditorExample } from '~/containers/examples/EditorExample';
|
|
||||||
import { Sprites } from '~/sprites/Sprites';
|
import { Sprites } from '~/sprites/Sprites';
|
||||||
import { URLS } from '~/constants/urls';
|
import { URLS } from '~/constants/urls';
|
||||||
import { Modal } from '~/containers/dialogs/Modal';
|
import { Modal } from '~/containers/dialogs/Modal';
|
||||||
|
@ -39,12 +37,10 @@ const Component: FC<IProps> = ({ modal: { is_shown } }) => {
|
||||||
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path={URLS.BASE} component={FlowLayout} />
|
<Route exact path={URLS.BASE} component={FlowLayout} />
|
||||||
<Route path={URLS.EXAMPLES.IMAGE} component={ImageExample} />
|
|
||||||
<Route path={URLS.EXAMPLES.EDITOR} component={EditorExample} />
|
|
||||||
<Route path={URLS.NODE_URL(':id')} component={NodeLayout} />
|
<Route path={URLS.NODE_URL(':id')} component={NodeLayout} />
|
||||||
<Route path={URLS.BORIS} component={BorisLayout} />
|
<Route path={URLS.BORIS} component={BorisLayout} />
|
||||||
<Route path={URLS.ERRORS.NOT_FOUND} component={ErrorNotFound} />
|
<Route path={URLS.ERRORS.NOT_FOUND} component={ErrorNotFound} />
|
||||||
<Route path={URLS.PROFILE_PAGE} component={ProfilePage} />
|
<Route path={URLS.PROFILE_PAGE(':username')} component={ProfilePage} />
|
||||||
|
|
||||||
<Redirect to="/" />
|
<Redirect to="/" />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
@ -57,7 +53,4 @@ const Component: FC<IProps> = ({ modal: { is_shown } }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(
|
export default connect(mapStateToProps, mapDispatchToProps)(hot(module)(Component));
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(hot(module)(Component));
|
|
||||||
|
|
130
src/containers/dialogs/PhotoSwipe/index.tsx
Normal file
130
src/containers/dialogs/PhotoSwipe/index.tsx
Normal file
|
@ -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 mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
|
const PhotoSwipeUnconnected: FC<Props> = ({ photoswipe, modalSetShown }) => {
|
||||||
|
let ref = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="pswp" tabIndex={-1} role="dialog" aria-hidden="true" ref={ref}>
|
||||||
|
<div className={classNames('pswp__bg', styles.bg)} />
|
||||||
|
<div className={classNames('pswp__scroll-wrap', styles.wrap)}>
|
||||||
|
<div className="pswp__container">
|
||||||
|
<div className="pswp__item" />
|
||||||
|
<div className="pswp__item" />
|
||||||
|
<div className="pswp__item" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pswp__ui pswp__ui--hidden">
|
||||||
|
<div className={classNames('pswp__top-bar', styles.bar)}>
|
||||||
|
<div className="pswp__counter" />
|
||||||
|
<button className="pswp__button pswp__button--close" title="Close (Esc)" />
|
||||||
|
|
||||||
|
<div className="pswp__preloader">
|
||||||
|
<div className="pswp__preloader__icn">
|
||||||
|
<div className="pswp__preloader__cut">
|
||||||
|
<div className="pswp__preloader__donut" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
|
||||||
|
<div className="pswp__share-tooltip" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="pswp__button pswp__button--arrow--left"
|
||||||
|
title="Previous (arrow left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button className="pswp__button pswp__button--arrow--right" title="Next (arrow right)" />
|
||||||
|
|
||||||
|
<div className="pswp__caption">
|
||||||
|
<div className="pswp__caption__center" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PhotoSwipe = connect(mapStateToProps, mapDispatchToProps)(PhotoSwipeUnconnected);
|
||||||
|
|
||||||
|
export { PhotoSwipe };
|
15
src/containers/dialogs/PhotoSwipe/styles.scss
Normal file
15
src/containers/dialogs/PhotoSwipe/styles.scss
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
.wrap {
|
||||||
|
:global(.pswp__img) {
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
@include outer_shadow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, useState, useCallback } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
import { BetterScrollDialog } from '../BetterScrollDialog';
|
import { BetterScrollDialog } from '../BetterScrollDialog';
|
||||||
import { ProfileInfo } from '~/containers/profile/ProfileInfo';
|
import { ProfileInfo } from '~/containers/profile/ProfileInfo';
|
||||||
import { IDialogProps } from '~/redux/types';
|
import { IDialogProps } from '~/redux/types';
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
import React, { FC } from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Card } from '~/components/containers/Card';
|
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { Group } from '~/components/containers/Group';
|
|
||||||
import { CellGrid } from '~/components/containers/CellGrid';
|
|
||||||
import { Panel } from '~/components/containers/Panel';
|
|
||||||
import { Scroll } from '~/components/containers/Scroll';
|
|
||||||
import { Tags } from '~/components/node/Tags';
|
|
||||||
import { Button } from '~/components/input/Button';
|
|
||||||
import { Filler } from '~/components/containers/Filler';
|
|
||||||
import { InputText } from '~/components/input/InputText';
|
|
||||||
import { Icon } from '~/components/input/Icon';
|
|
||||||
import { Grid } from '~/components/containers/Grid';
|
|
||||||
|
|
||||||
interface IProps {}
|
|
||||||
|
|
||||||
const EditorExample: FC<IProps> = () => (
|
|
||||||
<Card className={styles.wrap} seamless>
|
|
||||||
<Group horizontal className={styles.group} seamless>
|
|
||||||
<div className={styles.editor}>
|
|
||||||
<Panel className={classNames(styles.editor_panel, styles.editor_image_panel)}>
|
|
||||||
<Scroll>
|
|
||||||
<CellGrid className={styles.editor_image_container} size={200}>
|
|
||||||
<div className={styles.editor_image} />
|
|
||||||
<div className={styles.editor_image} />
|
|
||||||
<div className={styles.editor_image} />
|
|
||||||
<div className={styles.editor_image} />
|
|
||||||
</CellGrid>
|
|
||||||
</Scroll>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel>
|
|
||||||
<Grid columns="1fr" stretchy>
|
|
||||||
<Card className={styles.feature_card}>
|
|
||||||
<div className={styles.cover} />
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.panel}>
|
|
||||||
<Panel>
|
|
||||||
<Group>
|
|
||||||
<InputText title="Заголовок" />
|
|
||||||
|
|
||||||
<Tags
|
|
||||||
tags={[
|
|
||||||
{ title: 'Избранный' },
|
|
||||||
{ title: 'Плейлист' },
|
|
||||||
{ title: 'Просто' },
|
|
||||||
{ title: '+ фото' },
|
|
||||||
{ title: '+ с музыкой' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel stretchy />
|
|
||||||
|
|
||||||
<Panel>
|
|
||||||
<Button iconRight="play" stretchy>
|
|
||||||
Submit?
|
|
||||||
</Button>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
export { EditorExample };
|
|
|
@ -1,87 +0,0 @@
|
||||||
.wrap {
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: center;
|
|
||||||
display: flex;
|
|
||||||
background: $editor_bg;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch !important;
|
|
||||||
justify-content: stretch;
|
|
||||||
//flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: $editor_panel_bg;
|
|
||||||
flex: 1;
|
|
||||||
border-radius: $radius;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
|
||||||
flex: 2;
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor_image_panel {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor_image_container {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor_image {
|
|
||||||
background: transparentize(white, 0.95);
|
|
||||||
padding-bottom: 100%;
|
|
||||||
border-radius: $radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature_card {
|
|
||||||
height: 120px;
|
|
||||||
background: darken($main_bg_color, 6%);
|
|
||||||
color: transparentize(white, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font: $font_18_semibold;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
border-radius: $radius;
|
|
||||||
background: url("http://37.192.131.144/full/attached/2017/11/f01fdaaea789915284757634baf7cd11.jpg");
|
|
||||||
flex: 1;
|
|
||||||
height: 120px;
|
|
||||||
background-size: cover;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel_main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close_icon {
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
background: transparentize(white, 0.95);
|
|
||||||
flex: 0 0 24px;
|
|
||||||
border-radius: $radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
.views {
|
|
||||||
div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
import React, { FC } from 'react';
|
|
||||||
import { Card } from '~/components/containers/Card';
|
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { Padder } from '~/components/containers/Padder';
|
|
||||||
import { Group } from '~/components/containers/Group';
|
|
||||||
import { InputText } from '~/components/input/InputText';
|
|
||||||
import { Button } from '~/components/input/Button';
|
|
||||||
import { Filler } from '~/components/containers/Filler';
|
|
||||||
import { Icon } from '~/components/input/Icon';
|
|
||||||
|
|
||||||
interface IProps {}
|
|
||||||
|
|
||||||
const HorizontalExample: FC<IProps> = () => (
|
|
||||||
<div className={styles.wrap}>
|
|
||||||
<Card seamless className={styles.card}>
|
|
||||||
<div className={styles.editor}>
|
|
||||||
<div className={styles.uploads}>
|
|
||||||
<div className={styles.cell} />
|
|
||||||
<div className={styles.cell} />
|
|
||||||
<div className={styles.cell} />
|
|
||||||
<div className={styles.cell} />
|
|
||||||
<div className={styles.cell} />
|
|
||||||
<div className={styles.cell} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Padder className={styles.features}>
|
|
||||||
<Group horizontal>
|
|
||||||
<div className={styles.feature_add_btn}>
|
|
||||||
<Icon icon="plus" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.feature}>
|
|
||||||
<Group horizontal>
|
|
||||||
<div>ОБЛОЖКА</div>
|
|
||||||
<Icon icon="close" />
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Filler />
|
|
||||||
|
|
||||||
<div className={styles.feature_cell}>
|
|
||||||
<Icon icon="cell-single" size={24} />
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Padder>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Padder className={styles.panel}>
|
|
||||||
<Group horizontal>
|
|
||||||
<InputText title="Название" />
|
|
||||||
|
|
||||||
<Button title="Сохранить" iconRight="check" />
|
|
||||||
</Group>
|
|
||||||
</Padder>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export { HorizontalExample };
|
|
|
@ -1,91 +0,0 @@
|
||||||
.wrap {
|
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
background: darken($content_bg, 4%);
|
|
||||||
box-shadow: transparentize(black, 0.7) 0 10px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
|
||||||
background: $content_bg;
|
|
||||||
min-height: 200px;
|
|
||||||
min-width: 50vw;
|
|
||||||
|
|
||||||
border-radius: $radius;
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
@include outer_shadow();
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
}
|
|
||||||
|
|
||||||
.features {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature_add_btn {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: $red_gradient;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature {
|
|
||||||
background: lighten($content_bg, 4%);
|
|
||||||
padding: $gap $gap $gap 20px;
|
|
||||||
border-radius: 24px;
|
|
||||||
font: $font_14_semibold;
|
|
||||||
height: 40px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature_cell {
|
|
||||||
background: lighten($content_bg, 4%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 40px;
|
|
||||||
width: 40px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: $radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploads {
|
|
||||||
flex: 1;
|
|
||||||
padding: $gap;
|
|
||||||
// padding-bottom: 0;
|
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
grid-column-gap: $gap;
|
|
||||||
grid-row-gap: $gap;
|
|
||||||
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(30vw, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell {
|
|
||||||
background: lighten($content_bg, 6%);
|
|
||||||
border-radius: $radius;
|
|
||||||
padding-bottom: 100%;
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
import React, { FC } from 'react';
|
|
||||||
import range from 'ramda/es/range';
|
|
||||||
import { Card } from '~/components/containers/Card';
|
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { Group } from '~/components/containers/Group';
|
|
||||||
import { Padder } from '~/components/containers/Padder';
|
|
||||||
import { Comment } from '~/components/node/Comment';
|
|
||||||
import { NodePanel } from '~/components/node/NodePanel';
|
|
||||||
import { NodeRelated } from '~/components/node/NodeRelated';
|
|
||||||
import { Tags } from '~/components/node/Tags';
|
|
||||||
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
|
||||||
import { ImageSwitcher } from '~/components/node/ImageSwitcher';
|
|
||||||
|
|
||||||
interface IProps {}
|
|
||||||
|
|
||||||
const ImageExample: FC<IProps> = () => <Card className={styles.node} seamless></Card>;
|
|
||||||
|
|
||||||
export { ImageExample };
|
|
||||||
|
|
||||||
/*
|
|
||||||
<Padder className={styles.buttons}>
|
|
||||||
<Group>
|
|
||||||
<MenuButton title="На главной" description="плывет по течению" icon="star" />
|
|
||||||
|
|
||||||
<MenuButton title="Видно всем" icon="star" />
|
|
||||||
|
|
||||||
<MenuButton title="Редактировать" icon="star" />
|
|
||||||
</Group>
|
|
||||||
</Padder>
|
|
||||||
*/
|
|
|
@ -1,53 +0,0 @@
|
||||||
.image_container {
|
|
||||||
width: 100%;
|
|
||||||
background: $node_image_bg;
|
|
||||||
border-radius: $panel_radius 0 0 $panel_radius;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.image {
|
|
||||||
max-height: 800px;
|
|
||||||
opacity: 1;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: $radius $radius 0 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
align-items: stretch !important;
|
|
||||||
|
|
||||||
@include vertical_at_tablet;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments {
|
|
||||||
flex: 3 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
flex: 1 3;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-left: $gap / 2;
|
|
||||||
|
|
||||||
@include tablet {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.node {
|
|
||||||
background: $node_bg;
|
|
||||||
box-shadow: $node_shadow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
background: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
background: $node_buttons_bg;
|
|
||||||
flex: 1;
|
|
||||||
border-radius: $panel_radius;
|
|
||||||
box-shadow: $comment_shadow;
|
|
||||||
}
|
|
|
@ -1,67 +1,85 @@
|
||||||
import React, { FC, useEffect, useCallback } from "react";
|
import React, { FC, useEffect, useCallback } from 'react';
|
||||||
import { connect } from "react-redux";
|
import { connect } from 'react-redux';
|
||||||
import { FlowGrid } from "~/components/flow/FlowGrid";
|
import { FlowGrid } from '~/components/flow/FlowGrid';
|
||||||
import { selectFlow } from "~/redux/flow/selectors";
|
import { selectFlow } from '~/redux/flow/selectors';
|
||||||
import * as NODE_ACTIONS from "~/redux/node/actions";
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
import * as FLOW_ACTIONS from "~/redux/flow/actions";
|
import * as FLOW_ACTIONS from '~/redux/flow/actions';
|
||||||
import pick from "ramda/es/pick";
|
import pick from 'ramda/es/pick';
|
||||||
import { selectUser } from "~/redux/auth/selectors";
|
import { selectUser } from '~/redux/auth/selectors';
|
||||||
|
import { FlowHero } from '~/components/flow/FlowHero';
|
||||||
|
import styles from './styles.scss';
|
||||||
|
import { IState } from '~/redux/store';
|
||||||
|
import { FlowStamp } from '~/components/flow/FlowStamp';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = (state: IState) => ({
|
||||||
flow: pick(
|
flow: pick(['nodes', 'heroes', 'recent', 'updated', 'is_loading', 'search'], selectFlow(state)),
|
||||||
["nodes", "heroes", "recent", "updated", "is_loading"],
|
user: pick(['role', 'id'], selectUser(state)),
|
||||||
selectFlow(state)
|
|
||||||
),
|
|
||||||
user: pick(["role", "id"], selectUser(state))
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
nodeGotoNode: NODE_ACTIONS.nodeGotoNode,
|
nodeGotoNode: NODE_ACTIONS.nodeGotoNode,
|
||||||
flowSetCellView: FLOW_ACTIONS.flowSetCellView,
|
flowSetCellView: FLOW_ACTIONS.flowSetCellView,
|
||||||
flowGetMore: FLOW_ACTIONS.flowGetMore
|
flowGetMore: FLOW_ACTIONS.flowGetMore,
|
||||||
|
flowChangeSearch: FLOW_ACTIONS.flowChangeSearch,
|
||||||
|
flowLoadMoreSearch: FLOW_ACTIONS.flowLoadMoreSearch,
|
||||||
};
|
};
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> &
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
typeof mapDispatchToProps & {};
|
|
||||||
|
|
||||||
const FlowLayoutUnconnected: FC<IProps> = ({
|
const FlowLayoutUnconnected: FC<IProps> = ({
|
||||||
flow: { nodes, heroes, recent, updated, is_loading },
|
flow: { nodes, heroes, recent, updated, is_loading, search },
|
||||||
user,
|
user,
|
||||||
nodeGotoNode,
|
nodeGotoNode,
|
||||||
flowSetCellView,
|
flowSetCellView,
|
||||||
flowGetMore
|
flowGetMore,
|
||||||
|
flowChangeSearch,
|
||||||
|
flowLoadMoreSearch,
|
||||||
}) => {
|
}) => {
|
||||||
const loadMore = useCallback(() => {
|
const onLoadMore = useCallback(() => {
|
||||||
const pos =
|
const pos = window.scrollY + window.innerHeight - document.body.scrollHeight;
|
||||||
window.scrollY + window.innerHeight - document.body.scrollHeight;
|
|
||||||
|
|
||||||
if (is_loading || pos < -600) return;
|
if (is_loading || pos < -600) return;
|
||||||
|
|
||||||
flowGetMore();
|
flowGetMore();
|
||||||
}, [flowGetMore, is_loading]);
|
}, [flowGetMore, is_loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
const onLoadMoreSearch = useCallback(() => {
|
||||||
window.addEventListener("scroll", loadMore);
|
if (search.is_loading_more) return;
|
||||||
|
flowLoadMoreSearch();
|
||||||
|
}, [search.is_loading_more, flowLoadMoreSearch]);
|
||||||
|
|
||||||
return () => window.removeEventListener("scroll", loadMore);
|
useEffect(() => {
|
||||||
}, [loadMore]);
|
window.addEventListener('scroll', onLoadMore);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', onLoadMore);
|
||||||
|
}, [onLoadMore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlowGrid
|
<div className={styles.grid}>
|
||||||
nodes={nodes}
|
<div className={styles.hero}>
|
||||||
heroes={heroes}
|
<FlowHero heroes={heroes} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.stamp}>
|
||||||
|
<FlowStamp
|
||||||
recent={recent}
|
recent={recent}
|
||||||
updated={updated}
|
updated={updated}
|
||||||
onSelect={nodeGotoNode}
|
search={search}
|
||||||
|
flowChangeSearch={flowChangeSearch}
|
||||||
|
onLoadMore={onLoadMoreSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlowGrid
|
||||||
|
nodes={nodes}
|
||||||
user={user}
|
user={user}
|
||||||
|
onSelect={nodeGotoNode}
|
||||||
onChangeCellView={flowSetCellView}
|
onChangeCellView={flowSetCellView}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FlowLayout = connect(
|
const FlowLayout = connect(mapStateToProps, mapDispatchToProps)(FlowLayoutUnconnected);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(FlowLayoutUnconnected);
|
|
||||||
|
|
||||||
export { FlowLayout, FlowLayoutUnconnected };
|
export { FlowLayout, FlowLayoutUnconnected };
|
||||||
|
|
|
@ -4,3 +4,80 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cols: $content_width / $cell;
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
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 {
|
||||||
|
grid-row-end: span 2;
|
||||||
|
grid-column: -2 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,6 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: $content_width;
|
max-width: $content_width;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-bottom: 64px;
|
padding-bottom: 29px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +1,42 @@
|
||||||
import React, { FC, useEffect } from 'react';
|
import React, { FC, useEffect } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import * as NODE_ACTIONS from '~/redux/node/actions';
|
|
||||||
import { selectNode } from '~/redux/node/selectors';
|
import { selectNode } from '~/redux/node/selectors';
|
||||||
import { selectUser } from '~/redux/auth/selectors';
|
import { selectUser } from '~/redux/auth/selectors';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { NodeComments } from '~/components/node/NodeComments';
|
import { NodeComments } from '~/components/node/NodeComments';
|
||||||
import styles from './styles.scss';
|
import styles from './styles.scss';
|
||||||
import { CommentForm } from '~/components/node/CommentForm';
|
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
import boris from '~/sprites/boris_robot.svg';
|
import boris from '~/sprites/boris_robot.svg';
|
||||||
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
||||||
import { getRandomPhrase } from '~/constants/phrases';
|
import { getRandomPhrase } from '~/constants/phrases';
|
||||||
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
||||||
|
|
||||||
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
|
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
||||||
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
|
import * as BORIS_ACTIONS from '~/redux/boris/actions';
|
||||||
|
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 { Placeholder } from '~/components/placeholders/Placeholder';
|
||||||
|
import { selectBorisStats } from '~/redux/boris/selectors';
|
||||||
|
import { BorisStats } from '~/components/boris/BorisStats';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
node: selectNode(state),
|
node: selectNode(state),
|
||||||
user: selectUser(state),
|
user: selectUser(state),
|
||||||
|
stats: selectBorisStats(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
|
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
|
||||||
nodeLockComment: NODE_ACTIONS.nodeLockComment,
|
nodeLockComment: NODE_ACTIONS.nodeLockComment,
|
||||||
nodeEditComment: NODE_ACTIONS.nodeEditComment,
|
nodeEditComment: NODE_ACTIONS.nodeEditComment,
|
||||||
|
nodeLoadMoreComments: NODE_ACTIONS.nodeLoadMoreComments,
|
||||||
|
authSetUser: AUTH_ACTIONS.authSetUser,
|
||||||
|
modalShowPhotoswipe: MODAL_ACTIONS.modalShowPhotoswipe,
|
||||||
|
borisLoadStats: BORIS_ACTIONS.borisLoadStats,
|
||||||
};
|
};
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> &
|
type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
|
@ -31,20 +46,38 @@ type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
const id = 696;
|
const id = 696;
|
||||||
|
|
||||||
const BorisLayoutUnconnected: FC<IProps> = ({
|
const BorisLayoutUnconnected: FC<IProps> = ({
|
||||||
node: { is_loading, is_loading_comments, comments = [], comment_data },
|
node: { is_loading, is_loading_comments, comments = [], comment_data, comment_count },
|
||||||
user,
|
user,
|
||||||
user: { is_user },
|
user: { is_user, last_seen_boris },
|
||||||
nodeLoadNode,
|
nodeLoadNode,
|
||||||
nodeLockComment,
|
nodeLockComment,
|
||||||
nodeEditComment,
|
nodeEditComment,
|
||||||
|
nodeLoadMoreComments,
|
||||||
|
modalShowPhotoswipe,
|
||||||
|
authSetUser,
|
||||||
|
borisLoadStats,
|
||||||
|
stats,
|
||||||
}) => {
|
}) => {
|
||||||
const title = getRandomPhrase('BORIS_TITLE');
|
const title = getRandomPhrase('BORIS_TITLE');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const last_comment = comments[0];
|
||||||
|
if (!last_comment) return;
|
||||||
|
if (last_seen_boris && !isBefore(new Date(last_seen_boris), new Date(last_comment.created_at)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
authSetUser({ last_seen_boris: last_comment.created_at });
|
||||||
|
}, [comments, last_seen_boris]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (is_loading) return;
|
if (is_loading) return;
|
||||||
nodeLoadNode(id, 'DESC');
|
nodeLoadNode(id, 'DESC');
|
||||||
}, [nodeLoadNode, id]);
|
}, [nodeLoadNode, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
borisLoadStats();
|
||||||
|
}, [borisLoadStats]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<div className={styles.cover} />
|
<div className={styles.cover} />
|
||||||
|
@ -53,33 +86,13 @@ const BorisLayoutUnconnected: FC<IProps> = ({
|
||||||
<div className={styles.caption}>
|
<div className={styles.caption}>
|
||||||
<div className={styles.caption_text}>{title}</div>
|
<div className={styles.caption_text}>{title}</div>
|
||||||
</div>
|
</div>
|
||||||
<img src={boris} />
|
|
||||||
|
<img src={boris} alt="Борис" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.column}>
|
<Card className={styles.content}>
|
||||||
<div className={styles.daygrid}>
|
<Group className={styles.grid}>
|
||||||
<div className={styles.label}>Убежищу сегодня:</div>
|
|
||||||
<div className={styles.day}>10</div>
|
|
||||||
<div>лет</div>
|
|
||||||
<div className={styles.day}>2</div>
|
|
||||||
<div>месяца</div>
|
|
||||||
|
|
||||||
<div className={styles.line} />
|
|
||||||
|
|
||||||
<div className={styles.label}>Мы собрали:</div>
|
|
||||||
<div className={styles.day}>2374</div>
|
|
||||||
<div>поста</div>
|
|
||||||
<div className={styles.day}>14765</div>
|
|
||||||
<div>комментариев</div>
|
|
||||||
<div className={styles.day}>4260</div>
|
|
||||||
<div>файла</div>
|
|
||||||
<div className={styles.day}>54</div>
|
|
||||||
<div>жителя</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Group className={styles.content}>
|
|
||||||
{is_user && <NodeCommentForm is_before />}
|
{is_user && <NodeCommentForm is_before />}
|
||||||
|
|
||||||
{is_loading_comments ? (
|
{is_loading_comments ? (
|
||||||
|
@ -88,20 +101,45 @@ const BorisLayoutUnconnected: FC<IProps> = ({
|
||||||
<NodeComments
|
<NodeComments
|
||||||
comments={comments}
|
comments={comments}
|
||||||
comment_data={comment_data}
|
comment_data={comment_data}
|
||||||
|
comment_count={comment_count}
|
||||||
user={user}
|
user={user}
|
||||||
onDelete={nodeLockComment}
|
onDelete={nodeLockComment}
|
||||||
onEdit={nodeEditComment}
|
onEdit={nodeEditComment}
|
||||||
|
onLoadMore={nodeLoadMoreComments}
|
||||||
|
modalShowPhotoswipe={modalShowPhotoswipe}
|
||||||
|
order="ASC"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Group className={styles.stats}>
|
||||||
|
<Sticky>
|
||||||
|
<Group className={styles.stats__container}>
|
||||||
|
<div className={styles.stats__about}>
|
||||||
|
<h4>Господи-боженьки, где это я?</h4>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Всё впорядке, это — главный штаб Суицидальных Роботов, строителей Убежища.
|
||||||
|
</p>
|
||||||
|
<p>Здесь мы сидим и слушаем всё, что вас беспокоит.</p>
|
||||||
|
<p>Все виновные будут наказаны. Невиновные, впрочем, тоже. </p>
|
||||||
|
<p className="grey">// Такова жизнь.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.stats__wrap}>
|
||||||
|
<BorisStats stats={stats} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Sticky>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BorisLayout = connect(
|
const BorisLayout = connect(mapStateToProps, mapDispatchToProps)(BorisLayoutUnconnected);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(BorisLayoutUnconnected);
|
|
||||||
|
|
||||||
export { BorisLayout };
|
export { BorisLayout };
|
||||||
|
|
|
@ -6,23 +6,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 3;
|
flex: 4;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: $gap;
|
|
||||||
background: $content_bg;
|
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
flex: 0 1 $limited_width;
|
padding: 0;
|
||||||
|
background: $node_bg;
|
||||||
|
box-shadow: inset transparentize(mix($wisegreen, white, 60%), 0.6) 0 1px;
|
||||||
|
|
||||||
|
@include desktop {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.grid {
|
||||||
flex: 1;
|
padding: $gap;
|
||||||
background: $content_bg;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
margin-right: $gap;
|
|
||||||
border-radius: $radius;
|
|
||||||
padding: $gap * 2;
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
|
@ -33,7 +30,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: 50% 0% no-repeat url("~/sprites/boris_bg.svg");
|
background: 50% 0% no-repeat url('~/sprites/boris_bg.svg');
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,44 +38,16 @@
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daygrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 100%;
|
|
||||||
column-gap: $gap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day {
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font: $font_14_regular;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
opacity: 0.5;
|
|
||||||
grid-column: 1/3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
grid-column: 1/3;
|
|
||||||
height: $gap * 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex: 0 1 $limited_width;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
// margin: auto;
|
|
||||||
|
|
||||||
// @include tablet {
|
@include tablet {
|
||||||
// width: 100%;
|
flex-direction: column-reverse;
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
|
@ -99,8 +68,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@include tablet {
|
@include tablet {
|
||||||
height: 100px;
|
height: 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -113,7 +83,6 @@
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: $limited_width;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
@ -131,13 +100,64 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
display: none;
|
||||||
|
|
||||||
padding: $gap;
|
padding: $gap;
|
||||||
font-size: 48px;
|
font-size: 32px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.caption_text {
|
.caption_text {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
align-self: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 10px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font: $font_20_semibold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: $gap * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
background: darken($content_bg, 4%);
|
||||||
|
border-radius: 0 $radius $radius 0;
|
||||||
|
box-shadow: inset transparentize(mix($wisegreen, white, 60%), 0.6) 0 1px;
|
||||||
|
padding: $gap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font: $font_12_semibold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-top: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__about {
|
||||||
|
line-height: 1.4em;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: $gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wrap {
|
||||||
|
@include tablet {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,21 +10,32 @@ import { Group } from '~/components/containers/Group';
|
||||||
import { Padder } from '~/components/containers/Padder';
|
import { Padder } from '~/components/containers/Padder';
|
||||||
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
||||||
import { NodeRelated } from '~/components/node/NodeRelated';
|
import { NodeRelated } from '~/components/node/NodeRelated';
|
||||||
import * as styles from './styles.scss';
|
|
||||||
import { NodeComments } from '~/components/node/NodeComments';
|
import { NodeComments } from '~/components/node/NodeComments';
|
||||||
import { NodeTags } from '~/components/node/NodeTags';
|
import { NodeTags } from '~/components/node/NodeTags';
|
||||||
import { NODE_COMPONENTS, NODE_INLINES } from '~/redux/node/constants';
|
import {
|
||||||
import * as NODE_ACTIONS from '~/redux/node/actions';
|
NODE_COMPONENTS,
|
||||||
import { CommentForm } from '~/components/node/CommentForm';
|
NODE_INLINES,
|
||||||
|
NODE_HEADS,
|
||||||
|
INodeComponentProps,
|
||||||
|
} from '~/redux/node/constants';
|
||||||
import { selectUser } from '~/redux/auth/selectors';
|
import { selectUser } from '~/redux/auth/selectors';
|
||||||
import pick from 'ramda/es/pick';
|
import pick from 'ramda/es/pick';
|
||||||
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
|
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
|
||||||
import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
|
import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
|
||||||
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
||||||
|
import { Sticky } from '~/components/containers/Sticky';
|
||||||
|
import { Footer } from '~/components/main/Footer';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
import * as styles from './styles.scss';
|
||||||
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
|
import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
|
import { IState } from '~/redux/store';
|
||||||
|
import { selectModal } from '~/redux/modal/selectors';
|
||||||
|
|
||||||
|
const mapStateToProps = (state: IState) => ({
|
||||||
node: selectNode(state),
|
node: selectNode(state),
|
||||||
user: selectUser(state),
|
user: selectUser(state),
|
||||||
|
modal: pick(['is_shown'])(selectModal(state)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
|
@ -37,6 +48,8 @@ const mapDispatchToProps = {
|
||||||
nodeLock: NODE_ACTIONS.nodeLock,
|
nodeLock: NODE_ACTIONS.nodeLock,
|
||||||
nodeLockComment: NODE_ACTIONS.nodeLockComment,
|
nodeLockComment: NODE_ACTIONS.nodeLockComment,
|
||||||
nodeEditComment: NODE_ACTIONS.nodeEditComment,
|
nodeEditComment: NODE_ACTIONS.nodeEditComment,
|
||||||
|
nodeLoadMoreComments: NODE_ACTIONS.nodeLoadMoreComments,
|
||||||
|
modalShowPhotoswipe: MODAL_ACTIONS.modalShowPhotoswipe,
|
||||||
};
|
};
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> &
|
type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
|
@ -48,7 +61,16 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
match: {
|
match: {
|
||||||
params: { id },
|
params: { id },
|
||||||
},
|
},
|
||||||
node: { is_loading, is_loading_comments, comments = [], current: node, related, comment_data },
|
node: {
|
||||||
|
is_loading,
|
||||||
|
is_loading_comments,
|
||||||
|
comments = [],
|
||||||
|
current: node,
|
||||||
|
related,
|
||||||
|
comment_data,
|
||||||
|
comment_count,
|
||||||
|
},
|
||||||
|
modal: { is_shown: is_modal_shown },
|
||||||
user,
|
user,
|
||||||
user: { is_user },
|
user: { is_user },
|
||||||
nodeGotoNode,
|
nodeGotoNode,
|
||||||
|
@ -60,6 +82,8 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
nodeSetCoverImage,
|
nodeSetCoverImage,
|
||||||
nodeLockComment,
|
nodeLockComment,
|
||||||
nodeEditComment,
|
nodeEditComment,
|
||||||
|
nodeLoadMoreComments,
|
||||||
|
modalShowPhotoswipe,
|
||||||
}) => {
|
}) => {
|
||||||
const [layout, setLayout] = useState({});
|
const [layout, setLayout] = useState({});
|
||||||
|
|
||||||
|
@ -81,14 +105,29 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
const can_like = useMemo(() => canLikeNode(node, user), [node, user]);
|
const can_like = useMemo(() => canLikeNode(node, user), [node, user]);
|
||||||
const can_star = useMemo(() => canStarNode(node, user), [node, user]);
|
const can_star = useMemo(() => canStarNode(node, user), [node, user]);
|
||||||
|
|
||||||
|
const head = node && node.type && NODE_HEADS[node.type];
|
||||||
const block = node && node.type && NODE_COMPONENTS[node.type];
|
const block = node && node.type && NODE_COMPONENTS[node.type];
|
||||||
const inline_block = node && node.type && NODE_INLINES[node.type];
|
const inline = node && node.type && NODE_INLINES[node.type];
|
||||||
|
|
||||||
const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]);
|
const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]);
|
||||||
const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]);
|
const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]);
|
||||||
const onStar = useCallback(() => nodeStar(node.id), [nodeStar, node]);
|
const onStar = useCallback(() => nodeStar(node.id), [nodeStar, node]);
|
||||||
const onLock = useCallback(() => nodeLock(node.id, !node.deleted_at), [nodeStar, node]);
|
const onLock = useCallback(() => nodeLock(node.id, !node.deleted_at), [nodeStar, node]);
|
||||||
|
|
||||||
|
const createNodeBlock = useCallback(
|
||||||
|
(block: FC<INodeComponentProps>) =>
|
||||||
|
block &&
|
||||||
|
createElement(block, {
|
||||||
|
node,
|
||||||
|
is_loading,
|
||||||
|
updateLayout,
|
||||||
|
layout,
|
||||||
|
modalShowPhotoswipe,
|
||||||
|
is_modal_shown,
|
||||||
|
}),
|
||||||
|
[node, is_loading, updateLayout, layout, modalShowPhotoswipe, is_modal_shown]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node.cover) return;
|
if (!node.cover) return;
|
||||||
nodeSetCoverImage(node.cover);
|
nodeSetCoverImage(node.cover);
|
||||||
|
@ -96,11 +135,17 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
}, [nodeSetCoverImage, node.cover]);
|
}, [nodeSetCoverImage, node.cover]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{createNodeBlock(head)}
|
||||||
|
|
||||||
<Card className={styles.node} seamless>
|
<Card className={styles.node} seamless>
|
||||||
{block && createElement(block, { node, is_loading, updateLayout, layout })}
|
{createNodeBlock(block)}
|
||||||
|
|
||||||
<NodePanel
|
<NodePanel
|
||||||
node={pick(['title', 'user', 'is_liked', 'is_heroic', 'deleted_at', 'created_at'], node)}
|
node={pick(
|
||||||
|
['title', 'user', 'is_liked', 'is_heroic', 'deleted_at', 'created_at', 'like_count'],
|
||||||
|
node
|
||||||
|
)}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
can_edit={can_edit}
|
can_edit={can_edit}
|
||||||
can_like={can_like}
|
can_like={can_like}
|
||||||
|
@ -119,26 +164,21 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
<Padder>
|
<Padder>
|
||||||
<Group horizontal className={styles.content}>
|
<Group horizontal className={styles.content}>
|
||||||
<Group className={styles.comments}>
|
<Group className={styles.comments}>
|
||||||
{inline_block && (
|
{inline && <div className={styles.inline}>{createNodeBlock(inline)}</div>}
|
||||||
<div className={styles.inline_block}>
|
|
||||||
{createElement(inline_block, {
|
|
||||||
node,
|
|
||||||
is_loading,
|
|
||||||
updateLayout,
|
|
||||||
layout,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{is_loading || is_loading_comments || (!comments.length && !inline_block) ? (
|
{is_loading || is_loading_comments || (!comments.length && !inline) ? (
|
||||||
<NodeNoComments is_loading={is_loading_comments || is_loading} />
|
<NodeNoComments is_loading={is_loading_comments || is_loading} />
|
||||||
) : (
|
) : (
|
||||||
<NodeComments
|
<NodeComments
|
||||||
comments={comments}
|
comments={comments}
|
||||||
comment_data={comment_data}
|
comment_data={comment_data}
|
||||||
|
comment_count={comment_count}
|
||||||
user={user}
|
user={user}
|
||||||
onDelete={nodeLockComment}
|
onDelete={nodeLockComment}
|
||||||
onEdit={nodeEditComment}
|
onEdit={nodeEditComment}
|
||||||
|
onLoadMore={nodeLoadMoreComments}
|
||||||
|
modalShowPhotoswipe={modalShowPhotoswipe}
|
||||||
|
order="DESC"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -146,9 +186,14 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
|
<Sticky>
|
||||||
<Group style={{ flex: 1, minWidth: 0 }}>
|
<Group style={{ flex: 1, minWidth: 0 }}>
|
||||||
{!is_loading && (
|
{!is_loading && (
|
||||||
<NodeTags is_editable={is_user} tags={node.tags} onChange={onTagsChange} />
|
<NodeTags
|
||||||
|
is_editable={is_user}
|
||||||
|
tags={node.tags}
|
||||||
|
onChange={onTagsChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{is_loading && <NodeRelatedPlaceholder />}
|
{is_loading && <NodeRelatedPlaceholder />}
|
||||||
|
@ -159,26 +204,34 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
|
||||||
Object.keys(related.albums)
|
Object.keys(related.albums)
|
||||||
.filter(album => related.albums[album].length > 0)
|
.filter(album => related.albums[album].length > 0)
|
||||||
.map(album => (
|
.map(album => (
|
||||||
<NodeRelated title={album} items={related.albums[album]} key={album} />
|
<NodeRelated
|
||||||
|
title={album}
|
||||||
|
items={related.albums[album]}
|
||||||
|
key={album}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!is_loading && related && related.similar && related.similar.length > 0 && (
|
{!is_loading &&
|
||||||
|
related &&
|
||||||
|
related.similar &&
|
||||||
|
related.similar.length > 0 && (
|
||||||
<NodeRelated title="ПОХОЖИЕ" items={related.similar} />
|
<NodeRelated title="ПОХОЖИЕ" items={related.similar} />
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
</Sticky>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Padder>
|
</Padder>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const NodeLayout = connect(
|
const NodeLayout = connect(mapStateToProps, mapDispatchToProps)(NodeLayoutUnconnected);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(NodeLayoutUnconnected);
|
|
||||||
|
|
||||||
export { NodeLayout, NodeLayoutUnconnected };
|
export { NodeLayout, NodeLayoutUnconnected };
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
.content {
|
.content {
|
||||||
align-items: stretch !important;
|
align-items: stretch !important;
|
||||||
|
|
||||||
@include vertical_at_tablet;
|
@include vertical_at_tablet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +10,10 @@
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex: 2 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
@ -21,16 +24,13 @@
|
||||||
padding-left: $gap / 2;
|
padding-left: $gap / 2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
@include tablet {
|
@media (max-width: 1024px) {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
padding-top: $comment_height / 2;
|
||||||
|
flex: 1 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.node {
|
|
||||||
background: $node_bg;
|
|
||||||
box-shadow: $node_shadow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
background: $node_buttons_bg;
|
background: $node_buttons_bg;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
@ -1,10 +1,54 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, useEffect } from 'react';
|
||||||
import styles from './styles.scss';
|
import styles from './styles.scss';
|
||||||
|
import { ProfilePageLeft } from '../ProfilePageLeft';
|
||||||
|
import { Switch, Route, RouteComponentProps } from 'react-router';
|
||||||
|
import { IState } from '~/redux/store';
|
||||||
|
import { selectAuthProfile } from '~/redux/auth/selectors';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
||||||
|
|
||||||
interface IProps {}
|
const mapStateToProps = (state: IState) => ({
|
||||||
|
profile: selectAuthProfile(state),
|
||||||
|
});
|
||||||
|
|
||||||
const ProfilePage: FC<IProps> = ({}) => {
|
const mapDispatchToProps = {
|
||||||
return <div className={styles.wrap}>PROFILE</div>;
|
authLoadProfile: AUTH_ACTIONS.authLoadProfile,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Props = ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps &
|
||||||
|
RouteComponentProps<{ username: string }> & {};
|
||||||
|
|
||||||
|
const ProfilePageUnconnected: FC<Props> = ({
|
||||||
|
profile,
|
||||||
|
authLoadProfile,
|
||||||
|
match: {
|
||||||
|
params: { username },
|
||||||
|
},
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
authLoadProfile(username);
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.right}>
|
||||||
|
<Switch>
|
||||||
|
<Route path="/profile/:username" render={() => <div>DEFAULT</div>} />
|
||||||
|
<Route path="/profile/:username/tab" render={() => <div>TAB</div>} />
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.left}>
|
||||||
|
<ProfilePageLeft profile={profile} username={username} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfilePage = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(ProfilePageUnconnected);
|
||||||
|
|
||||||
export { ProfilePage };
|
export { ProfilePage };
|
||||||
|
|
|
@ -2,6 +2,19 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: $content_bg;
|
background: $content_bg;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: stretch;
|
||||||
|
box-shadow: $node_shadow;
|
||||||
|
border-radius: $radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 1;
|
||||||
|
background: darken($content_bg, 2%);
|
||||||
|
border-radius: 0 $radius $radius 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: 4;
|
||||||
}
|
}
|
||||||
|
|
62
src/containers/profile/ProfilePageLeft/index.tsx
Normal file
62
src/containers/profile/ProfilePageLeft/index.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import styles from './styles.scss';
|
||||||
|
import { IAuthState } from '~/redux/auth/types';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
import { PRESETS, URLS } from '~/constants/urls';
|
||||||
|
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
profile: IAuthState['profile'];
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfilePageLeft: FC<IProps> = ({ username, profile }) => {
|
||||||
|
const thumb = useMemo(() => {
|
||||||
|
if (!profile || !profile.user || !profile.user.photo) return '';
|
||||||
|
|
||||||
|
return getURL(profile.user.photo, PRESETS.small_hero);
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.avatar} style={{ backgroundImage: `url('${thumb}')` }} />
|
||||||
|
|
||||||
|
<div className={styles.region_wrap}>
|
||||||
|
<div className={styles.region}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
{profile.is_loading ? <Placeholder /> : profile.user.fullname}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.username}>
|
||||||
|
{profile.is_loading ? <Placeholder /> : `~${profile.user.username}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.menu}>
|
||||||
|
<Link to={`${URLS.PROFILE_PAGE(username)}/`}>
|
||||||
|
<Icon icon="profile" size={20} />
|
||||||
|
Профиль
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link to={`${URLS.PROFILE_PAGE(username)}/settings`}>
|
||||||
|
<Icon icon="settings" size={20} />
|
||||||
|
Настройки
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link to={`${URLS.PROFILE_PAGE(username)}/messages`}>
|
||||||
|
<Icon icon="messages" size={20} />
|
||||||
|
Сообщения
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile && profile.user && profile.user.description && false && (
|
||||||
|
<div className={styles.description}>{profile.user.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ProfilePageLeft };
|
90
src/containers/profile/ProfilePageLeft/styles.scss
Normal file
90
src/containers/profile/ProfilePageLeft/styles.scss
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 75%;
|
||||||
|
border-radius: 0 $radius 0 0;
|
||||||
|
background: 50% 50% no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region_wrap {
|
||||||
|
width: 100%;
|
||||||
|
// padding: 0 10px;
|
||||||
|
position: relative;
|
||||||
|
margin-top: -$radius;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region {
|
||||||
|
// background: $content_bg;
|
||||||
|
background: darken($content_bg, 2%);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0 $radius $radius 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font: $font_24_semibold;
|
||||||
|
color: white;
|
||||||
|
padding: $gap $gap 0 $gap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font: $font_14_semibold;
|
||||||
|
padding: 0 $gap $gap $gap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
color: transparentize(white, 0.5);
|
||||||
|
margin-top: $gap / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
padding: $gap 0 $gap 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font: $font_18_semibold;
|
||||||
|
padding: $gap $gap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
opacity: 0.5;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: $gap;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
padding: $gap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
// background: darken($content_bg, 2%);
|
||||||
|
background: darken($content_bg, 4%);
|
||||||
|
// margin: 0 $gap;
|
||||||
|
border-radius: 0 0 $radius $radius;
|
||||||
|
}
|
|
@ -44,6 +44,11 @@ export const authOpenProfile = (username: string, tab?: IAuthState['profile']['t
|
||||||
tab,
|
tab,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const authLoadProfile = (username: string) => ({
|
||||||
|
type: AUTH_USER_ACTIONS.LOAD_PROFILE,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
export const authSetProfile = (profile: Partial<IAuthState['profile']>) => ({
|
export const authSetProfile = (profile: Partial<IAuthState['profile']>) => ({
|
||||||
type: AUTH_USER_ACTIONS.SET_PROFILE,
|
type: AUTH_USER_ACTIONS.SET_PROFILE,
|
||||||
profile,
|
profile,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { api, errorMiddleware, resultMiddleware, configWithToken } from '~/utils/api';
|
import { api, errorMiddleware, resultMiddleware, configWithToken } from '~/utils/api';
|
||||||
import { API } from '~/constants/api';
|
import { API } from '~/constants/api';
|
||||||
import { IResultWithStatus, IMessage } from '~/redux/types';
|
import { IResultWithStatus, IMessage, INotification } from '~/redux/types';
|
||||||
import { userLoginTransform } from '~/redux/auth/transforms';
|
import { userLoginTransform } from '~/redux/auth/transforms';
|
||||||
import { IUser } from './types';
|
import { IUser } from './types';
|
||||||
|
|
||||||
|
@ -55,7 +55,9 @@ export const apiAuthGetUpdates = ({
|
||||||
access,
|
access,
|
||||||
exclude_dialogs,
|
exclude_dialogs,
|
||||||
last,
|
last,
|
||||||
}): Promise<IResultWithStatus<{ message: IMessage }>> =>
|
}): Promise<
|
||||||
|
IResultWithStatus<{ notifications: INotification[]; boris: { commented_at: string } }>
|
||||||
|
> =>
|
||||||
api
|
api
|
||||||
.get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs, last } }))
|
.get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs, last } }))
|
||||||
.then(resultMiddleware)
|
.then(resultMiddleware)
|
||||||
|
|
|
@ -11,6 +11,7 @@ export const AUTH_USER_ACTIONS = {
|
||||||
|
|
||||||
GOT_AUTH_POST_MESSAGE: 'GOT_POST_MESSAGE',
|
GOT_AUTH_POST_MESSAGE: 'GOT_POST_MESSAGE',
|
||||||
OPEN_PROFILE: 'OPEN_PROFILE',
|
OPEN_PROFILE: 'OPEN_PROFILE',
|
||||||
|
LOAD_PROFILE: 'LOAD_PROFILE',
|
||||||
SET_PROFILE: 'SET_PROFILE',
|
SET_PROFILE: 'SET_PROFILE',
|
||||||
GET_MESSAGES: 'GET_MESSAGES',
|
GET_MESSAGES: 'GET_MESSAGES',
|
||||||
SEND_MESSAGE: 'SEND_MESSAGE',
|
SEND_MESSAGE: 'SEND_MESSAGE',
|
||||||
|
@ -61,6 +62,7 @@ export const EMPTY_USER: IUser = {
|
||||||
|
|
||||||
last_seen: null,
|
last_seen: null,
|
||||||
last_seen_messages: null,
|
last_seen_messages: null,
|
||||||
|
last_seen_boris: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IApiUser {
|
export interface IApiUser {
|
||||||
|
|
|
@ -14,6 +14,7 @@ const INITIAL_STATE: IAuthState = {
|
||||||
updates: {
|
updates: {
|
||||||
last: null,
|
last: null,
|
||||||
notifications: [],
|
notifications: [],
|
||||||
|
boris_commented_at: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
authSetRestore,
|
authSetRestore,
|
||||||
authRequestRestoreCode,
|
authRequestRestoreCode,
|
||||||
authRestorePassword,
|
authRestorePassword,
|
||||||
|
authLoadProfile,
|
||||||
} from '~/redux/auth/actions';
|
} from '~/redux/auth/actions';
|
||||||
import {
|
import {
|
||||||
apiUserLogin,
|
apiUserLogin,
|
||||||
|
@ -38,8 +39,9 @@ import {
|
||||||
selectAuthUser,
|
selectAuthUser,
|
||||||
selectAuthUpdates,
|
selectAuthUpdates,
|
||||||
selectAuthRestore,
|
selectAuthRestore,
|
||||||
|
selectAuth,
|
||||||
} from './selectors';
|
} from './selectors';
|
||||||
import { IResultWithStatus, INotification, IMessageNotification } from '../types';
|
import { IResultWithStatus, INotification, IMessageNotification, Unwrap } from '../types';
|
||||||
import { IUser, IAuthState } from './types';
|
import { IUser, IAuthState } from './types';
|
||||||
import { REHYDRATE, RehydrateAction } from 'redux-persist';
|
import { REHYDRATE, RehydrateAction } from 'redux-persist';
|
||||||
import { selectModal } from '~/redux/modal/selectors';
|
import { selectModal } from '~/redux/modal/selectors';
|
||||||
|
@ -82,6 +84,10 @@ function* sendLoginRequestSaga({ username, password }: ReturnType<typeof userSen
|
||||||
}
|
}
|
||||||
|
|
||||||
function* refreshUser() {
|
function* refreshUser() {
|
||||||
|
const { token }: ReturnType<typeof selectAuth> = yield select(selectAuth);
|
||||||
|
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
data: { user },
|
data: { user },
|
||||||
|
@ -104,7 +110,6 @@ function* refreshUser() {
|
||||||
function* checkUserSaga({ key }: RehydrateAction) {
|
function* checkUserSaga({ key }: RehydrateAction) {
|
||||||
if (key !== 'auth') return;
|
if (key !== 'auth') return;
|
||||||
yield call(refreshUser);
|
yield call(refreshUser);
|
||||||
// yield put(authOpenProfile("gvorcek", "settings"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function* gotPostMessageSaga({ token }: ReturnType<typeof gotAuthPostMessage>) {
|
function* gotPostMessageSaga({ token }: ReturnType<typeof gotAuthPostMessage>) {
|
||||||
|
@ -127,9 +132,8 @@ function* logoutSaga() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenProfile>) {
|
function* loadProfile({ username }: ReturnType<typeof authLoadProfile>) {
|
||||||
yield put(modalShowDialog(DIALOGS.PROFILE));
|
yield put(authSetProfile({ is_loading: true }));
|
||||||
yield put(authSetProfile({ is_loading: true, tab }));
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
|
@ -137,10 +141,22 @@ function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenP
|
||||||
} = yield call(reqWrapper, apiAuthGetUserProfile, { username });
|
} = yield call(reqWrapper, apiAuthGetUserProfile, { username });
|
||||||
|
|
||||||
if (error || !user) {
|
if (error || !user) {
|
||||||
return yield put(modalSetShown(false));
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
yield put(authSetProfile({ is_loading: false, user, messages: [] }));
|
yield put(authSetProfile({ is_loading: false, user, messages: [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenProfile>) {
|
||||||
|
yield put(modalShowDialog(DIALOGS.PROFILE));
|
||||||
|
yield put(authSetProfile({ tab }));
|
||||||
|
|
||||||
|
const success: boolean = yield call(loadProfile, authLoadProfile(username));
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return yield put(modalSetShown(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function* getMessages({ username }: ReturnType<typeof authGetMessages>) {
|
function* getMessages({ username }: ReturnType<typeof authGetMessages>) {
|
||||||
|
@ -232,32 +248,42 @@ function* sendMessage({ message, onSuccess }: ReturnType<typeof authSendMessage>
|
||||||
}
|
}
|
||||||
|
|
||||||
function* getUpdates() {
|
function* getUpdates() {
|
||||||
const user = yield select(selectAuthUser);
|
const user: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser);
|
||||||
|
|
||||||
if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return;
|
if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return;
|
||||||
|
|
||||||
const modal: IModalState = yield select(selectModal);
|
const modal: IModalState = yield select(selectModal);
|
||||||
const profile: IAuthState['profile'] = yield select(selectAuthProfile);
|
const profile: IAuthState['profile'] = yield select(selectAuthProfile);
|
||||||
const { last }: IAuthState['updates'] = yield select(selectAuthUpdates);
|
const { last, boris_commented_at }: IAuthState['updates'] = yield select(selectAuthUpdates);
|
||||||
const exclude_dialogs =
|
const exclude_dialogs =
|
||||||
modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user.id ? profile.user.id : null;
|
modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user.id ? profile.user.id : null;
|
||||||
|
|
||||||
const { error, data }: IResultWithStatus<{ notifications: INotification[] }> = yield call(
|
const { error, data }: Unwrap<ReturnType<typeof apiAuthGetUpdates>> = yield call(
|
||||||
reqWrapper,
|
reqWrapper,
|
||||||
apiAuthGetUpdates,
|
apiAuthGetUpdates,
|
||||||
{ exclude_dialogs, last: last || user.last_seen_messages }
|
{ exclude_dialogs, last: last || user.last_seen_messages }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error || !data || !data.notifications || !data.notifications.length) return;
|
if (error || !data) {
|
||||||
|
return;
|
||||||
const { notifications } = data;
|
}
|
||||||
|
|
||||||
|
if (data.notifications && data.notifications.length) {
|
||||||
yield put(
|
yield put(
|
||||||
authSetUpdates({
|
authSetUpdates({
|
||||||
last: notifications[0].created_at,
|
last: data.notifications[0].created_at,
|
||||||
notifications,
|
notifications: data.notifications,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.boris && data.boris.commented_at && boris_commented_at !== data.boris.commented_at) {
|
||||||
|
yield put(
|
||||||
|
authSetUpdates({
|
||||||
|
boris_commented_at: data.boris.commented_at,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function* startPollingSaga() {
|
function* startPollingSaga() {
|
||||||
|
@ -354,6 +380,7 @@ function* authSaga() {
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.SEND_LOGIN_REQUEST, sendLoginRequestSaga);
|
yield takeLatest(AUTH_USER_ACTIONS.SEND_LOGIN_REQUEST, sendLoginRequestSaga);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga);
|
yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile);
|
yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile);
|
||||||
|
yield takeLatest(AUTH_USER_ACTIONS.LOAD_PROFILE, loadProfile);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.GET_MESSAGES, getMessages);
|
yield takeLatest(AUTH_USER_ACTIONS.GET_MESSAGES, getMessages);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.SEND_MESSAGE, sendMessage);
|
yield takeLatest(AUTH_USER_ACTIONS.SEND_MESSAGE, sendMessage);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.SET_LAST_SEEN_MESSAGES, setLastSeenMessages);
|
yield takeLatest(AUTH_USER_ACTIONS.SET_LAST_SEEN_MESSAGES, setLastSeenMessages);
|
||||||
|
|
|
@ -18,6 +18,7 @@ export interface IUser {
|
||||||
|
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
last_seen_messages: string;
|
last_seen_messages: string;
|
||||||
|
last_seen_boris: string;
|
||||||
|
|
||||||
is_activated: boolean;
|
is_activated: boolean;
|
||||||
is_user: boolean;
|
is_user: boolean;
|
||||||
|
@ -30,6 +31,7 @@ export type IAuthState = Readonly<{
|
||||||
updates: {
|
updates: {
|
||||||
last: string;
|
last: string;
|
||||||
notifications: INotification[];
|
notifications: INotification[];
|
||||||
|
boris_commented_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
|
|
16
src/redux/boris/actions.ts
Normal file
16
src/redux/boris/actions.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { IBorisState } from './reducer';
|
||||||
|
import { BORIS_ACTIONS } from './constants';
|
||||||
|
|
||||||
|
export const borisSet = (state: Partial<IBorisState>) => ({
|
||||||
|
type: BORIS_ACTIONS.SET_BORIS,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const borisSetStats = (stats: Partial<IBorisState['stats']>) => ({
|
||||||
|
type: BORIS_ACTIONS.SET_STATS,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const borisLoadStats = () => ({
|
||||||
|
type: BORIS_ACTIONS.LOAD_STATS,
|
||||||
|
});
|
13
src/redux/boris/api.ts
Normal file
13
src/redux/boris/api.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import git from '~/stats/git.json';
|
||||||
|
import { API } from '~/constants/api';
|
||||||
|
import { api, resultMiddleware, errorMiddleware } from '~/utils/api';
|
||||||
|
import { IBorisState, IStatBackend } from './reducer';
|
||||||
|
import { IResultWithStatus } from '../types';
|
||||||
|
|
||||||
|
export const getBorisGitStats = (): Promise<IBorisState['stats']['git']> => Promise.resolve(git);
|
||||||
|
|
||||||
|
export const getBorisBackendStats = (): Promise<IResultWithStatus<IStatBackend>> =>
|
||||||
|
api
|
||||||
|
.get(API.BORIS.GET_BACKEND_STATS)
|
||||||
|
.then(resultMiddleware)
|
||||||
|
.catch(errorMiddleware);
|
8
src/redux/boris/constants.ts
Normal file
8
src/redux/boris/constants.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
const prefix = `BORIS.`;
|
||||||
|
|
||||||
|
export const BORIS_ACTIONS = {
|
||||||
|
SET_BORIS: `${prefix}SET_BORIS`,
|
||||||
|
SET_STATS: `${prefix}SET_STATS`,
|
||||||
|
|
||||||
|
LOAD_STATS: `${prefix}LOAD_STATS`,
|
||||||
|
};
|
20
src/redux/boris/handlers.ts
Normal file
20
src/redux/boris/handlers.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { IBorisState } from './reducer';
|
||||||
|
import { BORIS_ACTIONS } from './constants';
|
||||||
|
|
||||||
|
const borisSet = (current: IBorisState, { state }: ReturnType<typeof borisSet>) => ({
|
||||||
|
...current,
|
||||||
|
...state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const borisSetStats = (state: IBorisState, { stats }: ReturnType<typeof borisSetStats>) => ({
|
||||||
|
...state,
|
||||||
|
stats: {
|
||||||
|
...state.stats,
|
||||||
|
...stats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BORIS_HANDLERS = {
|
||||||
|
[BORIS_ACTIONS.SET_BORIS]: borisSet,
|
||||||
|
[BORIS_ACTIONS.SET_STATS]: borisSetStats,
|
||||||
|
};
|
47
src/redux/boris/reducer.ts
Normal file
47
src/redux/boris/reducer.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { createReducer } from '~/utils/reducer';
|
||||||
|
import { BORIS_HANDLERS } from './handlers';
|
||||||
|
|
||||||
|
export type IStatGitRow = {
|
||||||
|
commit: string;
|
||||||
|
subject: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IStatBackend = {
|
||||||
|
users: {
|
||||||
|
total: number;
|
||||||
|
alive: number;
|
||||||
|
};
|
||||||
|
nodes: {
|
||||||
|
images: number;
|
||||||
|
audios: number;
|
||||||
|
videos: number;
|
||||||
|
texts: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
comments: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
files: {
|
||||||
|
count: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IBorisState = Readonly<{
|
||||||
|
stats: {
|
||||||
|
git: IStatGitRow[];
|
||||||
|
backend: IStatBackend;
|
||||||
|
is_loading: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const BORIS_INITIAL_STATE: IBorisState = {
|
||||||
|
stats: {
|
||||||
|
git: [],
|
||||||
|
backend: null,
|
||||||
|
is_loading: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createReducer(BORIS_INITIAL_STATE, BORIS_HANDLERS);
|
24
src/redux/boris/sagas.ts
Normal file
24
src/redux/boris/sagas.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { takeLatest, put, call } from 'redux-saga/effects';
|
||||||
|
import { BORIS_ACTIONS } from './constants';
|
||||||
|
import { borisSetStats } from './actions';
|
||||||
|
import { getBorisGitStats, getBorisBackendStats } from './api';
|
||||||
|
import { Unwrap } from '../types';
|
||||||
|
|
||||||
|
function* loadStats() {
|
||||||
|
yield put(borisSetStats({ is_loading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const git: Unwrap<ReturnType<typeof getBorisGitStats>> = yield call(getBorisGitStats);
|
||||||
|
const backend: Unwrap<ReturnType<typeof getBorisBackendStats>> = yield call(
|
||||||
|
getBorisBackendStats
|
||||||
|
);
|
||||||
|
|
||||||
|
yield put(borisSetStats({ git, backend: backend.data, is_loading: false }));
|
||||||
|
} catch (e) {
|
||||||
|
yield put(borisSetStats({ git: [], backend: null, is_loading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function* borisSaga() {
|
||||||
|
yield takeLatest(BORIS_ACTIONS.LOAD_STATS, loadStats);
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue