mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 12:56:41 +07:00
Merge branch 'master' of https://github.com/muerwre/vault-frontend
This commit is contained in:
commit
d6ff3bcdca
142 changed files with 6877 additions and 3841 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -17,4 +17,5 @@
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnSaveTimeout": 750,
|
"editor.formatOnSaveTimeout": 750,
|
||||||
},
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
}
|
}
|
||||||
|
|
5674
package-lock.json
generated
5674
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
@ -14,10 +14,10 @@
|
||||||
"url": "https://github.com/muerwre/my-empty-react-project"
|
"url": "https://github.com/muerwre/my-empty-react-project"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.6.3",
|
"@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/react-router": "^5.0.3",
|
"@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",
|
||||||
"babel-core": "^6.26.3",
|
"babel-core": "^6.26.3",
|
||||||
|
@ -42,7 +42,7 @@
|
||||||
"ts-node": "^8.4.1",
|
"ts-node": "^8.4.1",
|
||||||
"typescript": "^3.6.4",
|
"typescript": "^3.6.4",
|
||||||
"uglifyjs-webpack-plugin": "^1.3.0",
|
"uglifyjs-webpack-plugin": "^1.3.0",
|
||||||
"webpack": "^4.41.0",
|
"webpack": "^4.41.2",
|
||||||
"webpack-cli": "^3.3.9",
|
"webpack-cli": "^3.3.9",
|
||||||
"webpack-dev-server": "^3.8.2"
|
"webpack-dev-server": "^3.8.2"
|
||||||
},
|
},
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
"@hot-loader/react-dom": "^16.10.2",
|
"@hot-loader/react-dom": "^16.10.2",
|
||||||
"@types/classnames": "^2.2.7",
|
"@types/classnames": "^2.2.7",
|
||||||
"@types/node": "^11.13.22",
|
"@types/node": "^11.13.22",
|
||||||
"@types/ramda": "^0.26.29",
|
"@types/ramda": "^0.26.33",
|
||||||
"@types/react": "16.8.23",
|
"@types/react": "16.8.23",
|
||||||
"@typescript-eslint/eslint-plugin": "^1.13.0",
|
"@typescript-eslint/eslint-plugin": "^1.13.0",
|
||||||
"@typescript-eslint/parser": "^1.13.0",
|
"@typescript-eslint/parser": "^1.13.0",
|
||||||
|
@ -61,7 +61,7 @@
|
||||||
"clean-webpack-plugin": "^0.1.9",
|
"clean-webpack-plugin": "^0.1.9",
|
||||||
"connected-react-router": "^6.3.2",
|
"connected-react-router": "^6.3.2",
|
||||||
"date-fns": "^2.4.1",
|
"date-fns": "^2.4.1",
|
||||||
"dotenv": "^8.1.0",
|
"dotenv": "^8.2.0",
|
||||||
"dotenv-webpack": "^1.7.0",
|
"dotenv-webpack": "^1.7.0",
|
||||||
"eslint": "^5.16.0",
|
"eslint": "^5.16.0",
|
||||||
"eslint-config-airbnb": "^17.1.1",
|
"eslint-config-airbnb": "^17.1.1",
|
||||||
|
@ -104,6 +104,7 @@
|
||||||
"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",
|
||||||
"throttle-debounce": "^2.1.0",
|
"throttle-debounce": "^2.1.0",
|
||||||
"tslint": "^5.20.0",
|
"tslint": "^5.20.0",
|
||||||
"tslint-config-airbnb": "^5.11.2",
|
"tslint-config-airbnb": "^5.11.2",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, useCallback, useState, useEffect } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Icon } from '~/components/input/Icon';
|
import { Icon } from '~/components/input/Icon';
|
||||||
import { Filler } from '~/components/containers/Filler';
|
import { Filler } from '~/components/containers/Filler';
|
||||||
|
@ -7,29 +7,85 @@ import { connect } from 'react-redux';
|
||||||
import pick from 'ramda/es/pick';
|
import pick from 'ramda/es/pick';
|
||||||
import { selectPlayer } from '~/redux/player/selectors';
|
import { selectPlayer } from '~/redux/player/selectors';
|
||||||
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
||||||
|
import { IPlayerProgress, Player } from '~/utils/player';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
|
||||||
const mapStateToProps = state => pick(['status'], selectPlayer(state));
|
const mapStateToProps = state => pick(['status', 'file'], selectPlayer(state));
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
playerPlay: PLAYER_ACTIONS.playerPlay,
|
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||||
playerPause: PLAYER_ACTIONS.playerPause,
|
playerPause: PLAYER_ACTIONS.playerPause,
|
||||||
|
playerSeek: PLAYER_ACTIONS.playerSeek,
|
||||||
|
playerStop: PLAYER_ACTIONS.playerStop,
|
||||||
};
|
};
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
const PlayerBarUnconnected: FC<IProps> = ({ status }) => {
|
const PlayerBarUnconnected: FC<IProps> = ({
|
||||||
|
status,
|
||||||
|
playerPlay,
|
||||||
|
playerPause,
|
||||||
|
playerSeek,
|
||||||
|
playerStop,
|
||||||
|
file,
|
||||||
|
}) => {
|
||||||
|
const [progress, setProgress] = useState<IPlayerProgress>({ progress: 0, current: 0, total: 0 });
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
if (status === PLAYER_STATES.PLAYING) return playerPause();
|
||||||
|
return playerPlay();
|
||||||
|
}, [playerPlay, playerPause, status]);
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
({ detail }: { detail: IPlayerProgress }) => {
|
||||||
|
if (!detail || !detail.total) return;
|
||||||
|
setProgress(detail);
|
||||||
|
},
|
||||||
|
[setProgress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSeek = useCallback(
|
||||||
|
event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const { clientX, target } = event;
|
||||||
|
const { left, width } = target.getBoundingClientRect();
|
||||||
|
playerSeek((clientX - left) / width);
|
||||||
|
},
|
||||||
|
[playerSeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Player.on('playprogress', onProgress);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Player.off('playprogress', onProgress);
|
||||||
|
};
|
||||||
|
}, [onProgress]);
|
||||||
|
|
||||||
if (status === PLAYER_STATES.UNSET) return null;
|
if (status === PLAYER_STATES.UNSET) return null;
|
||||||
|
|
||||||
|
const metadata: IFile['metadata'] = path(['metadata'], file);
|
||||||
|
const title =
|
||||||
|
metadata &&
|
||||||
|
(metadata.title || [metadata.id3artist, metadata.id3title].filter(el => !!el).join(' - '));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.place}>
|
<div className={styles.place}>
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<div className={styles.status}>
|
<div className={styles.status}>
|
||||||
<div className={styles.playpause}>
|
<div className={styles.playpause} onClick={onClick}>
|
||||||
<Icon icon="play" />
|
{status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Filler />
|
<div className={styles.info}>
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
|
||||||
<div className={styles.close}>
|
<div className={styles.progress} onClick={onSeek}>
|
||||||
|
<div className={styles.bar} style={{ width: `${progress.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.close} onClick={playerStop}>
|
||||||
<Icon icon="close" />
|
<Icon icon="close" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,46 +1,43 @@
|
||||||
.place {
|
.place {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 54px;
|
height: $bar_height;
|
||||||
flex: 0 1 500px;
|
flex: 0 1 500px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.seeker {
|
|
||||||
transform: translate(0, -64px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
|
@include outer_shadow();
|
||||||
display: flex;
|
display: flex;
|
||||||
border-radius: 27px;
|
border-radius: $radius $radius 0 0;
|
||||||
background: $green_gradient;
|
// background: $main_gradient;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: rgba(0, 0, 0, 0.5) 0 2px 5px, inset rgba(255, 255, 255, 0.3) 0 1px,
|
background: lighten($content_bg, 6%);
|
||||||
inset rgba(0, 0, 0, 0.3) 0 -1px;
|
// box-shadow: rgba(0, 0, 0, 0.5) 0 2px 5px, inset rgba(255, 255, 255, 0.3) 1px 1px,
|
||||||
|
// inset rgba(0, 0, 0, 0.3) 0 -1px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 54px;
|
height: $bar_height;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
flex: 0 0 54px;
|
flex: 0 0 $bar_height;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
height: 54px;
|
height: $bar_height;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playpause,
|
.playpause,
|
||||||
.close {
|
.close {
|
||||||
flex: 0 0 48px;
|
flex: 0 0 $bar_height;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -49,7 +46,7 @@
|
||||||
svg {
|
svg {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
fill: $content_bg;
|
fill: darken(white, 50%);
|
||||||
stroke: none;
|
stroke: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,3 +57,51 @@
|
||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: darken(white, 50%);
|
||||||
|
font: $font_14_semibold;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: ' ';
|
||||||
|
top: 9px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: darken(white, 50%);
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
top: 7px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: darken(white, 50%);
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
56
src/components/bars/SubmitBar/index.tsx
Normal file
56
src/components/bars/SubmitBar/index.tsx
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
|
import { DIALOGS } from '~/redux/modal/constants';
|
||||||
|
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { NODE_TYPES } from '~/redux/node/constants';
|
||||||
|
|
||||||
|
const mapStateToProps = null;
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
nodeCreate: NODE_ACTIONS.nodeCreate,
|
||||||
|
// showDialog: MODAL_ACTIONS.modalShowDialog,
|
||||||
|
};
|
||||||
|
|
||||||
|
type IProps = typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
|
const SubmitBarUnconnected: FC<IProps> = ({ nodeCreate }) => {
|
||||||
|
const onOpenImageEditor = useCallback(() => nodeCreate(NODE_TYPES.IMAGE), [nodeCreate]);
|
||||||
|
const onOpenTextEditor = useCallback(() => nodeCreate(NODE_TYPES.TEXT), [nodeCreate]);
|
||||||
|
const onOpenVideoEditor = useCallback(() => nodeCreate(NODE_TYPES.VIDEO), [nodeCreate]);
|
||||||
|
const onOpenAudioEditor = useCallback(() => nodeCreate(NODE_TYPES.AUDIO), [nodeCreate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div onClick={onOpenImageEditor}>
|
||||||
|
<Icon icon="image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div onClick={onOpenTextEditor}>
|
||||||
|
<Icon icon="text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div onClick={onOpenVideoEditor}>
|
||||||
|
<Icon icon="video" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div onClick={onOpenAudioEditor}>
|
||||||
|
<Icon icon="audio" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.button}>
|
||||||
|
<Icon icon="plus" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubmitBar = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(SubmitBarUnconnected);
|
||||||
|
|
||||||
|
export { SubmitBar };
|
65
src/components/bars/SubmitBar/styles.scss
Normal file
65
src/components/bars/SubmitBar/styles.scss
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
.wrap {
|
||||||
|
position: absolute;
|
||||||
|
right: -($bar_height + $gap);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.panel {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $content_width + ($bar_height + $gap) * 2) {
|
||||||
|
position: relative;
|
||||||
|
right: 0;
|
||||||
|
margin-left: $gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background: $red_gradient;
|
||||||
|
width: $bar_height;
|
||||||
|
height: $bar_height;
|
||||||
|
border-radius: $bar_height / 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: lighten($content_bg, 4%);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding-bottom: $bar_height;
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
transform: translate(0, 100%);
|
||||||
|
transition: transform 250ms;
|
||||||
|
|
||||||
|
div {
|
||||||
|
@include outer_shadow;
|
||||||
|
height: $bar_height;
|
||||||
|
width: $bar_height;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,8 @@
|
||||||
.blur {
|
.blur {
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
transition: filter 0.25s;
|
transition: filter 0.25s;
|
||||||
|
will-change: filter;
|
||||||
|
// max-height: 100vh;
|
||||||
|
// width: 100vw;
|
||||||
|
// overflow: visible auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,21 +3,26 @@ import classNames from 'classnames';
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Card } from '../Card';
|
import { Card } from '../Card';
|
||||||
|
import { IUser } from '~/redux/auth/types';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
photo?: string;
|
// photo?: string;
|
||||||
|
user: IUser;
|
||||||
is_empty?: boolean;
|
is_empty?: boolean;
|
||||||
is_loading?: boolean;
|
is_loading?: boolean;
|
||||||
is_same?: boolean;
|
is_same?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CommentWrapper: FC<IProps> = ({
|
const CommentWrapper: FC<IProps> = ({
|
||||||
photo,
|
// photo,
|
||||||
children,
|
children,
|
||||||
is_empty,
|
is_empty,
|
||||||
is_loading,
|
is_loading,
|
||||||
className,
|
className,
|
||||||
is_same,
|
is_same,
|
||||||
|
user,
|
||||||
...props
|
...props
|
||||||
}) => (
|
}) => (
|
||||||
<Card
|
<Card
|
||||||
|
@ -26,9 +31,11 @@ const CommentWrapper: FC<IProps> = ({
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className={styles.thumb}>
|
<div className={styles.thumb}>
|
||||||
{!is_same && photo && (
|
<div
|
||||||
<div className={styles.thumb_image} style={{ backgroundImage: `url("${photo}")` }} />
|
className={styles.thumb_image}
|
||||||
)}
|
style={{ backgroundImage: `url("${getURL(path(['photo'], user))}")` }}
|
||||||
|
/>
|
||||||
|
<div className={styles.thumb_user}>~{path(['username'], user)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.text}>{children}</div>
|
<div className={styles.text}>{children}</div>
|
||||||
|
|
|
@ -14,17 +14,39 @@
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
:global(.comment-author) {
|
||||||
|
display: none !important;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
flex: 0 0 $comment_height;
|
flex: 0 0 $comment_height;
|
||||||
border-radius: $panel_radius 0 0 $panel_radius;
|
border-radius: $panel_radius 0 0 $panel_radius;
|
||||||
background-color: transparentize(black, 0.9);
|
background-color: transparentize(black, 0.9);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
flex-direction: row;
|
||||||
|
flex: 0 0 40px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: inset rgba(255, 255, 255, 0.05) 1px 1px, inset rgba(0, 0, 0, 0.1) -1px -1px;
|
||||||
|
border-radius: $panel_radius $panel_radius 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb_image {
|
.thumb_image {
|
||||||
|
@ -32,4 +54,26 @@
|
||||||
background: transparentize(white, 0.97) no-repeat 50% 50%;
|
background: transparentize(white, 0.97) no-repeat 50% 50%;
|
||||||
border-radius: $panel_radius 0 0 $panel_radius;
|
border-radius: $panel_radius 0 0 $panel_radius;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
flex: 0 0 $comment_height;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
border-radius: $panel_radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb_user {
|
||||||
|
display: none;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 $gap;
|
||||||
|
font: $font_14_medium;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,15 +4,6 @@ import * as styles from './styles.scss';
|
||||||
|
|
||||||
type IProps = React.HTMLAttributes<HTMLDivElement>;
|
type IProps = React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const Filler: FC<IProps> = ({
|
export const Filler: FC<IProps> = ({ className = '', ...props }) => (
|
||||||
className = '',
|
<div className={classNames(styles.filler, className)} {...props} />
|
||||||
...props
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.filler,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
26
src/components/containers/PageCover/index.tsx
Normal file
26
src/components/containers/PageCover/index.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import React, { FC, memo } from 'react';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { selectNode } from '~/redux/node/selectors';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import pick from 'ramda/es/pick';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
|
||||||
|
const mapStateToProps = state => pick(['current_cover_image'], selectNode(state));
|
||||||
|
|
||||||
|
type IProps = ReturnType<typeof mapStateToProps> & {};
|
||||||
|
|
||||||
|
const PageCoverUnconnected: FC<IProps> = memo(({ current_cover_image }) =>
|
||||||
|
current_cover_image
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className={styles.wrap}
|
||||||
|
style={{ backgroundImage: `url("${getURL(current_cover_image)}")` }}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const PageCover = connect(mapStateToProps)(PageCoverUnconnected);
|
||||||
|
export { PageCover };
|
35
src/components/containers/PageCover/styles.scss
Normal file
35
src/components/containers/PageCover/styles.scss
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: 50% 50% no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
animation: fadeIn 2s;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: ' ';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: url(~/sprites/stripes.svg) rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,12 +14,7 @@ interface IProps {
|
||||||
onScrollStop?: MouseEventHandler;
|
onScrollStop?: MouseEventHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Scroll = ({
|
const Scroll = ({ children, className = '', onRef = null, ...props }: IProps) => {
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
onRef = null,
|
|
||||||
...props
|
|
||||||
}: IProps) => {
|
|
||||||
const [ref, setRef] = useState(null);
|
const [ref, setRef] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -43,3 +38,5 @@ export const Scroll = ({
|
||||||
</Scrollbars>
|
</Scrollbars>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { Scroll };
|
||||||
|
|
76
src/components/editors/AudioEditor/index.tsx
Normal file
76
src/components/editors/AudioEditor/index.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import React, { FC, useCallback, useMemo } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import { ImageGrid } from '../ImageGrid';
|
||||||
|
import { AudioGrid } from '../AudioGrid';
|
||||||
|
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||||
|
import { selectUploads } from '~/redux/uploads/selectors';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
|
const mapStateToProps = selectUploads;
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps & {
|
||||||
|
data: INode;
|
||||||
|
setData: (val: INode) => void;
|
||||||
|
temp: string[];
|
||||||
|
setTemp: (val: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
|
||||||
|
const images = useMemo(
|
||||||
|
() => data.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
|
||||||
|
[data.files]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending_images = useMemo(
|
||||||
|
() =>
|
||||||
|
temp
|
||||||
|
.filter(id => !!statuses[id] && statuses[id].type === UPLOAD_TYPES.IMAGE)
|
||||||
|
.map(id => statuses[id]),
|
||||||
|
[temp, statuses]
|
||||||
|
);
|
||||||
|
|
||||||
|
const audios = useMemo(
|
||||||
|
() => data.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
|
||||||
|
[data.files]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending_audios = useMemo(
|
||||||
|
() =>
|
||||||
|
temp
|
||||||
|
.filter(id => !!statuses[id] && statuses[id].type === UPLOAD_TYPES.AUDIO)
|
||||||
|
.map(id => statuses[id]),
|
||||||
|
[temp, statuses]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setImages = useCallback(files => setData({ ...data, files: [...files, ...audios] }), [
|
||||||
|
setData,
|
||||||
|
data,
|
||||||
|
audios,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setAudios = useCallback(files => setData({ ...data, files: [...files, ...images] }), [
|
||||||
|
setData,
|
||||||
|
data,
|
||||||
|
images,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<ImageGrid files={images} setFiles={setImages} locked={pending_images} />
|
||||||
|
<AudioGrid files={audios} setFiles={setAudios} locked={pending_audios} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioEditor = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(AudioEditorUnconnected);
|
||||||
|
|
||||||
|
export { AudioEditor };
|
4
src/components/editors/AudioEditor/styles.scss
Normal file
4
src/components/editors/AudioEditor/styles.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.wrap {
|
||||||
|
padding-bottom: $upload_button_height + $gap;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
43
src/components/editors/AudioGrid/index.tsx
Normal file
43
src/components/editors/AudioGrid/index.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import { SortEnd } from 'react-sortable-hoc';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
import { IUploadStatus } from '~/redux/uploads/reducer';
|
||||||
|
import { moveArrItem } from '~/utils/fn';
|
||||||
|
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
files: IFile[];
|
||||||
|
setFiles: (val: IFile[]) => void;
|
||||||
|
locked: IUploadStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => {
|
||||||
|
const onMove = useCallback(
|
||||||
|
({ oldIndex, newIndex }: SortEnd) => {
|
||||||
|
setFiles(moveArrItem(oldIndex, newIndex, files.filter(file => !!file)) as IFile[]);
|
||||||
|
},
|
||||||
|
[setFiles, files]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(remove_id: IFile['id']) => {
|
||||||
|
setFiles(files.filter(file => file && file.id !== remove_id));
|
||||||
|
},
|
||||||
|
[setFiles, files]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableAudioGrid
|
||||||
|
onDrop={onDrop}
|
||||||
|
onSortEnd={onMove}
|
||||||
|
axis="xy"
|
||||||
|
items={files}
|
||||||
|
locked={locked}
|
||||||
|
pressDelay={window.innerWidth < 768 ? 200 : 0}
|
||||||
|
helperClass={styles.helper}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AudioGrid };
|
4
src/components/editors/AudioGrid/styles.scss
Normal file
4
src/components/editors/AudioGrid/styles.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.helper {
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: 10 !important;
|
||||||
|
}
|
25
src/components/editors/EditorAudioUploadButton/index.tsx
Normal file
25
src/components/editors/EditorAudioUploadButton/index.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { EditorUploadButton } from '~/components/editors/EditorUploadButton';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
data: INode;
|
||||||
|
setData: (val: INode) => void;
|
||||||
|
temp: string[];
|
||||||
|
setTemp: (val: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorAudioUploadButton: FC<IProps> = ({ data, setData, temp, setTemp }) => (
|
||||||
|
<EditorUploadButton
|
||||||
|
data={data}
|
||||||
|
setData={setData}
|
||||||
|
temp={temp}
|
||||||
|
setTemp={setTemp}
|
||||||
|
accept="audio/*"
|
||||||
|
icon="audio"
|
||||||
|
type={UPLOAD_TYPES.AUDIO}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { EditorAudioUploadButton };
|
25
src/components/editors/EditorImageUploadButton/index.tsx
Normal file
25
src/components/editors/EditorImageUploadButton/index.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { EditorUploadButton } from '~/components/editors/EditorUploadButton';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
data: INode;
|
||||||
|
setData: (val: INode) => void;
|
||||||
|
temp: string[];
|
||||||
|
setTemp: (val: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorImageUploadButton: FC<IProps> = ({ data, setData, temp, setTemp }) => (
|
||||||
|
<EditorUploadButton
|
||||||
|
data={data}
|
||||||
|
setData={setData}
|
||||||
|
temp={temp}
|
||||||
|
setTemp={setTemp}
|
||||||
|
accept="image/*"
|
||||||
|
icon="image"
|
||||||
|
type={UPLOAD_TYPES.IMAGE}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { EditorImageUploadButton };
|
|
@ -1,17 +1,21 @@
|
||||||
import React, { FC, ChangeEventHandler } from 'react';
|
import React, { FC, createElement } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { EditorUploadButton } from '~/components/editors/EditorUploadButton';
|
import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
data: INode;
|
data: INode;
|
||||||
setData: (val: INode) => void;
|
setData: (val: INode) => void;
|
||||||
onUpload: ChangeEventHandler<HTMLInputElement>;
|
temp: string[];
|
||||||
|
setTemp: (val: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditorPanel: FC<IProps> = ({ onUpload }) => (
|
const EditorPanel: FC<IProps> = ({ data, setData, temp, setTemp }) => (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<EditorUploadButton onUpload={onUpload} />
|
{NODE_PANEL_COMPONENTS[data.type] &&
|
||||||
|
NODE_PANEL_COMPONENTS[data.type].map((el, key) =>
|
||||||
|
createElement(el, { key, data, setData, temp, setTemp })
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -7,4 +7,17 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: $gap;
|
padding: $gap;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin: 0 $gap;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,21 +1,140 @@
|
||||||
import React, { FC, ChangeEventHandler } from 'react';
|
import React, { FC, useCallback, useEffect } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Icon } from '~/components/input/Icon';
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import { IFileWithUUID, INode, IFile } from '~/redux/types';
|
||||||
|
import uuid from 'uuid4';
|
||||||
|
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||||
|
import assocPath from 'ramda/es/assocPath';
|
||||||
|
import append from 'ramda/es/append';
|
||||||
|
import { selectUploads } from '~/redux/uploads/selectors';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { NODE_SETTINGS } from '~/redux/node/constants';
|
||||||
|
|
||||||
interface IProps {
|
const mapStateToProps = state => {
|
||||||
onUpload?: ChangeEventHandler<HTMLInputElement>;
|
const { statuses, files } = selectUploads(state);
|
||||||
}
|
|
||||||
|
|
||||||
const EditorUploadButton: FC<IProps> = ({
|
return { statuses, files };
|
||||||
onUpload,
|
};
|
||||||
}) => (
|
|
||||||
<div className={styles.wrap}>
|
|
||||||
<input type="file" onChange={onUpload} accept="image/*" multiple />
|
|
||||||
|
|
||||||
<div className={styles.icon}>
|
const mapDispatchToProps = {
|
||||||
<Icon size={32} icon="plus" />
|
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps & {
|
||||||
|
data: INode;
|
||||||
|
setData: (val: INode) => void;
|
||||||
|
temp: string[];
|
||||||
|
setTemp: (val: string[]) => void;
|
||||||
|
|
||||||
|
accept?: string;
|
||||||
|
icon?: string;
|
||||||
|
type?: typeof UPLOAD_TYPES[keyof typeof UPLOAD_TYPES];
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditorUploadButtonUnconnected: FC<IProps> = ({
|
||||||
|
data,
|
||||||
|
setData,
|
||||||
|
temp,
|
||||||
|
setTemp,
|
||||||
|
statuses,
|
||||||
|
files,
|
||||||
|
uploadUploadFiles,
|
||||||
|
accept = 'image/*',
|
||||||
|
icon = 'plus',
|
||||||
|
type = UPLOAD_TYPES.IMAGE,
|
||||||
|
}) => {
|
||||||
|
const eventPreventer = useCallback(event => event.preventDefault(), []);
|
||||||
|
|
||||||
|
const onUpload = useCallback(
|
||||||
|
(uploads: File[]) => {
|
||||||
|
const current = temp.length + data.files.length;
|
||||||
|
const limit = NODE_SETTINGS.MAX_FILES - current;
|
||||||
|
|
||||||
|
if (current >= NODE_SETTINGS.MAX_FILES) return;
|
||||||
|
|
||||||
|
const items: IFileWithUUID[] = Array.from(uploads).map(
|
||||||
|
(file: File): IFileWithUUID => ({
|
||||||
|
file,
|
||||||
|
temp_id: uuid(),
|
||||||
|
subject: UPLOAD_SUBJECTS.EDITOR,
|
||||||
|
target: UPLOAD_TARGETS.NODES,
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const temps = items.map(file => file.temp_id).slice(0, limit);
|
||||||
|
|
||||||
|
setTemp([...temp, ...temps]);
|
||||||
|
uploadUploadFiles(items);
|
||||||
|
},
|
||||||
|
[setTemp, uploadUploadFiles, temp, data, type]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFileAdd = useCallback(
|
||||||
|
(file: IFile) => {
|
||||||
|
setData(assocPath(['files'], append(file, data.files), data));
|
||||||
|
},
|
||||||
|
[data, setData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// const onDrop = useCallback(
|
||||||
|
// (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
// event.preventDefault();
|
||||||
|
|
||||||
|
// if (!event.dataTransfer || !event.dataTransfer.files || !event.dataTransfer.files.length)
|
||||||
|
// return;
|
||||||
|
|
||||||
|
// onUpload(Array.from(event.dataTransfer.files));
|
||||||
|
// },
|
||||||
|
// [onUpload]
|
||||||
|
// );
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('dragover', eventPreventer, false);
|
||||||
|
window.addEventListener('drop', eventPreventer, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('dragover', eventPreventer, false);
|
||||||
|
window.removeEventListener('drop', eventPreventer, false);
|
||||||
|
};
|
||||||
|
}, [eventPreventer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Object.entries(statuses).forEach(([id, status]) => {
|
||||||
|
if (temp.includes(id) && !!status.uuid && files[status.uuid]) {
|
||||||
|
onFileAdd(files[status.uuid]);
|
||||||
|
setTemp(temp.filter(el => el !== id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [statuses, files, temp, onFileAdd]);
|
||||||
|
|
||||||
|
const onInputChange = useCallback(
|
||||||
|
event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!event.target.files || !event.target.files.length) return;
|
||||||
|
|
||||||
|
onUpload(Array.from(event.target.files));
|
||||||
|
},
|
||||||
|
[onUpload]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<input type="file" onChange={onInputChange} accept={accept} multiple />
|
||||||
|
|
||||||
|
<div className={styles.icon}>
|
||||||
|
<Icon size={32} icon={icon} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
const EditorUploadButton = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(EditorUploadButtonUnconnected);
|
||||||
|
|
||||||
export { EditorUploadButton };
|
export { EditorUploadButton };
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
width: 52px;
|
@include outer_shadow();
|
||||||
height: 52px;
|
|
||||||
border-radius: 32px !important;
|
width: $upload_button_height;
|
||||||
|
height: $upload_button_height;
|
||||||
|
border-radius: ($upload_button_height / 2) !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
// opacity: 0.7;
|
// opacity: 0.7;
|
||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s;
|
||||||
background: $red_gradient;
|
background: $red_gradient;
|
||||||
box-shadow: $content_bg 0 0 5px 10px;
|
// box-shadow: $content_bg 0 0 5px 10px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
111
src/components/editors/EditorUploadCoverButton/index.tsx
Normal file
111
src/components/editors/EditorUploadCoverButton/index.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import React, { FC, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { INode, IFileWithUUID } from '~/redux/types';
|
||||||
|
import uuid from 'uuid4';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||||
|
import { selectUploads } from '~/redux/uploads/selectors';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
const { statuses, files } = selectUploads(state);
|
||||||
|
|
||||||
|
return { statuses, files };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps & {
|
||||||
|
data: INode;
|
||||||
|
setData: (data: INode) => void;
|
||||||
|
temp: string[];
|
||||||
|
setTemp: (val: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditorUploadCoverButtonUnconnected: FC<IProps> = ({
|
||||||
|
data,
|
||||||
|
setData,
|
||||||
|
files,
|
||||||
|
statuses,
|
||||||
|
uploadUploadFiles,
|
||||||
|
}) => {
|
||||||
|
const [cover_temp, setCoverTemp] = useState<string>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Object.entries(statuses).forEach(([id, status]) => {
|
||||||
|
if (cover_temp === id && !!status.uuid && files[status.uuid]) {
|
||||||
|
setData({ ...data, cover: files[status.uuid] });
|
||||||
|
setCoverTemp(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [statuses, files, cover_temp, setData, data]);
|
||||||
|
|
||||||
|
const onUpload = useCallback(
|
||||||
|
(uploads: File[]) => {
|
||||||
|
const items: IFileWithUUID[] = Array.from(uploads).map(
|
||||||
|
(file: File): IFileWithUUID => ({
|
||||||
|
file,
|
||||||
|
temp_id: uuid(),
|
||||||
|
subject: UPLOAD_SUBJECTS.EDITOR,
|
||||||
|
target: UPLOAD_TARGETS.NODES,
|
||||||
|
type: UPLOAD_TYPES.IMAGE,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setCoverTemp(path([0, 'temp_id'], items));
|
||||||
|
uploadUploadFiles(items);
|
||||||
|
},
|
||||||
|
[uploadUploadFiles, setCoverTemp]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onInputChange = useCallback(
|
||||||
|
event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!event.target.files || !event.target.files.length) return;
|
||||||
|
|
||||||
|
onUpload(Array.from(event.target.files));
|
||||||
|
},
|
||||||
|
[onUpload]
|
||||||
|
);
|
||||||
|
const onDropCover = useCallback(() => {
|
||||||
|
setData({ ...data, cover: null });
|
||||||
|
}, [setData, data]);
|
||||||
|
|
||||||
|
const background = data.cover ? getURL(data.cover) : null;
|
||||||
|
const status = cover_temp && path([cover_temp], statuses);
|
||||||
|
const preview = status && path(['preview'], status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div
|
||||||
|
className={styles.preview}
|
||||||
|
style={{ backgroundImage: `url("${preview || background}")` }}
|
||||||
|
>
|
||||||
|
<div className={styles.input}>
|
||||||
|
{!data.cover && <span>ОБЛОЖКА</span>}
|
||||||
|
<input type="file" accept="image/*" onChange={onInputChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.cover && (
|
||||||
|
<div className={styles.button} onClick={onDropCover}>
|
||||||
|
<Icon icon="close" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditorUploadCoverButton = connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(EditorUploadCoverButtonUnconnected);
|
||||||
|
|
||||||
|
export { EditorUploadCoverButton };
|
81
src/components/editors/EditorUploadCoverButton/styles.scss
Normal file
81
src/components/editors/EditorUploadCoverButton/styles.scss
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
.wrap {
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
|
height: $upload_button_height;
|
||||||
|
border-radius: ($upload_button_height / 2) !important;
|
||||||
|
position: relative;
|
||||||
|
border-radius: $radius;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
background: lighten($content_bg, 4%);
|
||||||
|
flex: 0 1 $upload_button_height * 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font: $font_16_medium;
|
||||||
|
text-shadow: rgba(0, 0, 0, 0.5) 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: ($upload_button_height / 2) !important;
|
||||||
|
background: 50% 50% no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
will-change: transform;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
width: $upload_button_height;
|
||||||
|
flex: 0 0 $upload_button_height;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: inset rgba(255, 255, 255, 0.05) 1px 1px, rgba(0, 0, 0, 0.3) -1px 0;
|
||||||
|
border-radius: $upload_button_height;
|
||||||
|
background: transparentize($color: lighten($content_bg, 4%), $amount: 0);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
svg {
|
||||||
|
fill: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { FC, ChangeEventHandler, DragEventHandler } from 'react';
|
import React, { FC, useMemo, useCallback } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { INode } from '~/redux/types';
|
import { INode, IFile } from '~/redux/types';
|
||||||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||||
import { selectUploads } from '~/redux/uploads/selectors';
|
import { selectUploads } from '~/redux/uploads/selectors';
|
||||||
import { ImageGrid } from '~/components/editors/ImageGrid';
|
import { ImageGrid } from '~/components/editors/ImageGrid';
|
||||||
import { IUploadStatus } from '~/redux/uploads/reducer';
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
const mapStateToProps = selectUploads;
|
const mapStateToProps = selectUploads;
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
|
@ -14,26 +14,25 @@ const mapDispatchToProps = {
|
||||||
type IProps = ReturnType<typeof mapStateToProps> &
|
type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
typeof mapDispatchToProps & {
|
typeof mapDispatchToProps & {
|
||||||
data: INode;
|
data: INode;
|
||||||
pending_files: IUploadStatus[];
|
|
||||||
|
|
||||||
setData: (val: INode) => void;
|
setData: (val: INode) => void;
|
||||||
onFileMove: (from: number, to: number) => void;
|
temp: string[];
|
||||||
onInputChange: ChangeEventHandler<HTMLInputElement>;
|
setTemp: (val: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImageEditorUnconnected: FC<IProps> = ({
|
const ImageEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
|
||||||
data,
|
const pending_files = useMemo(() => temp.filter(id => !!statuses[id]).map(id => statuses[id]), [
|
||||||
onFileMove,
|
temp,
|
||||||
onInputChange,
|
statuses,
|
||||||
pending_files,
|
]);
|
||||||
}) => (
|
|
||||||
<ImageGrid
|
const setFiles = useCallback((files: IFile[]) => setData({ ...data, files }), [data, setData]);
|
||||||
onFileMove={onFileMove}
|
|
||||||
items={data.files}
|
return (
|
||||||
locked={pending_files}
|
<div className={styles.wrap}>
|
||||||
onUpload={onInputChange}
|
<ImageGrid files={data.files} setFiles={setFiles} locked={pending_files} />
|
||||||
/>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ImageEditor = connect(
|
const ImageEditor = connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
|
|
|
@ -1,14 +1,4 @@
|
||||||
.uploads {
|
.wrap {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
padding-bottom: 60px;
|
padding-bottom: $upload_button_height + $gap;
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,59 +1,39 @@
|
||||||
import React, { FC, useCallback, ChangeEventHandler, DragEventHandler } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
|
import { SortEnd } from 'react-sortable-hoc';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { ImageUpload } from '~/components/upload/ImageUpload';
|
|
||||||
import { IFile } from '~/redux/types';
|
import { IFile } from '~/redux/types';
|
||||||
import { IUploadStatus } from '~/redux/uploads/reducer';
|
import { IUploadStatus } from '~/redux/uploads/reducer';
|
||||||
import { getURL } from '~/utils/dom';
|
import { moveArrItem } from '~/utils/fn';
|
||||||
|
import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
items: IFile[];
|
files: IFile[];
|
||||||
|
setFiles: (val: IFile[]) => void;
|
||||||
locked: IUploadStatus[];
|
locked: IUploadStatus[];
|
||||||
onFileMove: (o: number, n: number) => void;
|
|
||||||
onUpload?: ChangeEventHandler<HTMLInputElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableItem = SortableElement(({ children }) => (
|
const ImageGrid: FC<IProps> = ({ files, setFiles, locked }) => {
|
||||||
<div className={styles.item}>{children}</div>
|
const onMove = useCallback(
|
||||||
));
|
({ oldIndex, newIndex }: SortEnd) => {
|
||||||
|
setFiles(moveArrItem(oldIndex, newIndex, files.filter(file => !!file)) as IFile[]);
|
||||||
|
},
|
||||||
|
[setFiles, files]
|
||||||
|
);
|
||||||
|
|
||||||
const SortableList = SortableContainer(
|
const onDrop = useCallback(
|
||||||
({
|
(remove_id: IFile['id']) => {
|
||||||
items,
|
setFiles(files.filter(file => file && file.id !== remove_id));
|
||||||
locked,
|
},
|
||||||
}: {
|
[setFiles, files]
|
||||||
items: IFile[];
|
);
|
||||||
locked: IUploadStatus[];
|
|
||||||
onUpload: ChangeEventHandler<HTMLInputElement>;
|
|
||||||
}) => (
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{items.map((file, index) => (
|
|
||||||
<SortableItem key={file.id} index={index} collection={0}>
|
|
||||||
<ImageUpload id={file.id} thumb={getURL(file)} />
|
|
||||||
</SortableItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{locked.map((item, index) => (
|
|
||||||
<SortableItem key={item.temp_id} index={index} collection={1} disabled>
|
|
||||||
<ImageUpload thumb={item.preview} progress={item.progress} is_uploading />
|
|
||||||
</SortableItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const ImageGrid: FC<IProps> = ({ items, locked, onFileMove, onUpload }) => {
|
|
||||||
const onMove = useCallback(({ oldIndex, newIndex }) => onFileMove(oldIndex, newIndex), [
|
|
||||||
onFileMove,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableList
|
<SortableImageGrid
|
||||||
|
onDrop={onDrop}
|
||||||
onSortEnd={onMove}
|
onSortEnd={onMove}
|
||||||
axis="xy"
|
axis="xy"
|
||||||
items={items}
|
items={files}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
onUpload={onUpload}
|
|
||||||
pressDelay={window.innerWidth < 768 ? 200 : 0}
|
pressDelay={window.innerWidth < 768 ? 200 : 0}
|
||||||
helperClass={styles.helper}
|
helperClass={styles.helper}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,30 +1,4 @@
|
||||||
.grid {
|
|
||||||
min-height: 200px;
|
|
||||||
padding-bottom: 62px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-column-gap: $gap;
|
|
||||||
grid-row-gap: $gap;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
|
|
||||||
// display: flex;
|
|
||||||
// flex-wrap: wrap;
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(30vw, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
// flex: 0 4 25%;
|
|
||||||
// width: 25%;
|
|
||||||
// float: left;
|
|
||||||
// padding: $gap / 2;
|
|
||||||
z-index: 1;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper {
|
.helper {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
z-index: 10;
|
z-index: 10 !important;
|
||||||
}
|
}
|
||||||
|
|
38
src/components/editors/SortableAudioGrid/index.tsx
Normal file
38
src/components/editors/SortableAudioGrid/index.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { SortableContainer } from 'react-sortable-hoc';
|
||||||
|
import { AudioUpload } from '~/components/upload/AudioUpload';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { SortableImageGridItem } from '~/components/editors/SortableImageGridItem';
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
import { IUploadStatus } from '~/redux/uploads/reducer';
|
||||||
|
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
|
|
||||||
|
const SortableAudioGrid = SortableContainer(
|
||||||
|
({
|
||||||
|
items,
|
||||||
|
locked,
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
items: IFile[];
|
||||||
|
locked: IUploadStatus[];
|
||||||
|
onDrop: (file_id: IFile['id']) => void;
|
||||||
|
}) => (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{items
|
||||||
|
.filter(file => file && file.id)
|
||||||
|
.map((file, index) => (
|
||||||
|
<SortableImageGridItem key={file.id} index={index} collection={0}>
|
||||||
|
<AudioPlayer file={file} onDrop={onDrop} />
|
||||||
|
</SortableImageGridItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{locked.map((item, index) => (
|
||||||
|
<SortableImageGridItem key={item.temp_id} index={index} collection={1} disabled>
|
||||||
|
<AudioUpload title={item.name} progress={item.progress} is_uploading />
|
||||||
|
</SortableImageGridItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export { SortableAudioGrid };
|
13
src/components/editors/SortableAudioGrid/styles.scss
Normal file
13
src/components/editors/SortableAudioGrid/styles.scss
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
.grid {
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-column-gap: $gap;
|
||||||
|
grid-row-gap: $gap;
|
||||||
|
grid-template-columns: auto;
|
||||||
|
grid-template-rows: $comment_height;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(30vw, 1fr));
|
||||||
|
}
|
||||||
|
}
|
10
src/components/editors/SortableAudioGridItem/index.tsx
Normal file
10
src/components/editors/SortableAudioGridItem/index.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { SortableElement } from 'react-sortable-hoc';
|
||||||
|
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
|
const SortableAudioGridItem = SortableElement(({ children }) => (
|
||||||
|
<div className={styles.item}>{children}</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
export { SortableAudioGridItem };
|
4
src/components/editors/SortableAudioGridItem/styles.scss
Normal file
4
src/components/editors/SortableAudioGridItem/styles.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.item {
|
||||||
|
z-index: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
38
src/components/editors/SortableImageGrid/index.tsx
Normal file
38
src/components/editors/SortableImageGrid/index.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { SortableContainer } from 'react-sortable-hoc';
|
||||||
|
import { ImageUpload } from '~/components/upload/ImageUpload';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { SortableImageGridItem } from '~/components/editors/SortableImageGridItem';
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
import { IUploadStatus } from '~/redux/uploads/reducer';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
|
||||||
|
const SortableImageGrid = SortableContainer(
|
||||||
|
({
|
||||||
|
items,
|
||||||
|
locked,
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
items: IFile[];
|
||||||
|
locked: IUploadStatus[];
|
||||||
|
onDrop: (file_id: IFile['id']) => void;
|
||||||
|
}) => (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{items
|
||||||
|
.filter(file => file && file.id)
|
||||||
|
.map((file, index) => (
|
||||||
|
<SortableImageGridItem key={file.id} index={index} collection={0}>
|
||||||
|
<ImageUpload id={file.id} thumb={getURL(file)} onDrop={onDrop} />
|
||||||
|
</SortableImageGridItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{locked.map((item, index) => (
|
||||||
|
<SortableImageGridItem key={item.temp_id} index={index} collection={1} disabled>
|
||||||
|
<ImageUpload thumb={item.preview} progress={item.progress} is_uploading />
|
||||||
|
</SortableImageGridItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export { SortableImageGrid };
|
12
src/components/editors/SortableImageGrid/styles.scss
Normal file
12
src/components/editors/SortableImageGrid/styles.scss
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
.grid {
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
10
src/components/editors/SortableImageGridItem/index.tsx
Normal file
10
src/components/editors/SortableImageGridItem/index.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { SortableElement } from 'react-sortable-hoc';
|
||||||
|
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
|
const SortableImageGridItem = SortableElement(({ children }) => (
|
||||||
|
<div className={styles.item}>{children}</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
export { SortableImageGridItem };
|
4
src/components/editors/SortableImageGridItem/styles.scss
Normal file
4
src/components/editors/SortableImageGridItem/styles.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.item {
|
||||||
|
z-index: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
27
src/components/editors/TextEditor/index.tsx
Normal file
27
src/components/editors/TextEditor/index.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { Textarea } from '~/components/input/Textarea';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
data: INode;
|
||||||
|
setData: (val: INode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextEditor: FC<IProps> = ({ data, setData }) => {
|
||||||
|
const setText = useCallback(
|
||||||
|
(text: string) => setData({ ...data, blocks: [{ type: 'text', text }] }),
|
||||||
|
[data, setData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = (path(['blocks', 0, 'text'], data) as string) || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<Textarea value={text} handler={setText} minRows={6} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TextEditor };
|
5
src/components/editors/TextEditor/styles.scss
Normal file
5
src/components/editors/TextEditor/styles.scss
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.wrap {
|
||||||
|
& > div {
|
||||||
|
padding-bottom: 64px;
|
||||||
|
}
|
||||||
|
}
|
41
src/components/editors/VideoEditor/index.tsx
Normal file
41
src/components/editors/VideoEditor/index.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import React, { FC, useCallback, useMemo } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
import { InputText } from '~/components/input/InputText';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
data: INode;
|
||||||
|
setData: (val: INode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoEditor: FC<IProps> = ({ data, setData }) => {
|
||||||
|
const setUrl = useCallback(
|
||||||
|
(url: string) => setData({ ...data, blocks: [{ type: 'video', url }] }),
|
||||||
|
[data, setData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = (path(['blocks', 0, 'url'], data) as string) || '';
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const match =
|
||||||
|
url &&
|
||||||
|
url.match(
|
||||||
|
/http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?/
|
||||||
|
);
|
||||||
|
|
||||||
|
return match && match[1] ? `http://img.youtube.com/vi/${match[1]}/maxresdefault.jpg` : null;
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.preview} style={{ backgroundImage: preview && `url("${preview}")` }}>
|
||||||
|
<div className={styles.input_wrap}>
|
||||||
|
<div className={classnames(styles.input, { active: !!preview })}>
|
||||||
|
<InputText value={url} handler={setUrl} placeholder="Адрес видео" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { VideoEditor };
|
35
src/components/editors/VideoEditor/styles.scss
Normal file
35
src/components/editors/VideoEditor/styles.scss
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
.preview {
|
||||||
|
padding-top: 56.25%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: $radius;
|
||||||
|
// background: darken($color: $content_bg, $amount: 2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input_wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
// @include outer_shadow();
|
||||||
|
|
||||||
|
flex: 1 0 50%;
|
||||||
|
padding: $gap * 2;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $content_bg;
|
||||||
|
margin: 20px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:global(.active) {
|
||||||
|
background: $red;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,47 +1,103 @@
|
||||||
import React, { FC, useState, useCallback } from 'react';
|
import React, { FC, useState, useCallback, useEffect } from 'react';
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { URLS } from '~/constants/urls';
|
import { getURL } from '~/utils/dom';
|
||||||
import { getImageSize, getURL } from '~/utils/dom';
|
import classNames from 'classnames';
|
||||||
import classNames = require('classnames');
|
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import { flowSetCellView } from '~/redux/flow/actions';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
node: INode;
|
node: INode;
|
||||||
// height?: number;
|
|
||||||
// width?: number;
|
|
||||||
// title?: string;
|
|
||||||
// is_hero?: boolean;
|
|
||||||
// is_stamp?: boolean;
|
|
||||||
onSelect: (id: INode['id'], type: INode['type']) => void;
|
|
||||||
is_text?: boolean;
|
is_text?: boolean;
|
||||||
|
can_edit?: boolean;
|
||||||
|
|
||||||
|
onSelect: (id: INode['id'], type: INode['type']) => void;
|
||||||
|
onChangeCellView: typeof flowSetCellView;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Cell: FC<IProps> = ({ node: { id, title, brief, type }, onSelect, is_text = false }) => {
|
const Cell: FC<IProps> = ({
|
||||||
|
node: { id, title, thumbnail, type, flow, description },
|
||||||
|
can_edit,
|
||||||
|
onSelect,
|
||||||
|
onChangeCellView,
|
||||||
|
}) => {
|
||||||
const [is_loaded, setIsLoaded] = useState(false);
|
const [is_loaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
const onImageLoad = useCallback(() => {
|
const onImageLoad = useCallback(() => {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}, [setIsLoaded]);
|
}, [setIsLoaded]);
|
||||||
|
|
||||||
const onClick = useCallback(() => onSelect(id, type), [onSelect, id]);
|
const onClick = useCallback(() => onSelect(id, type), [onSelect, id, type]);
|
||||||
|
|
||||||
|
const text = (((flow && !!flow.show_description) || type === 'text') && description) || null;
|
||||||
|
|
||||||
|
const toggleViewDescription = useCallback(() => {
|
||||||
|
const show_description = !(flow && flow.show_description);
|
||||||
|
const display = (flow && flow.display) || 'single';
|
||||||
|
onChangeCellView(id, { show_description, display });
|
||||||
|
}, [id, flow, onChangeCellView]);
|
||||||
|
|
||||||
|
const setViewSingle = useCallback(() => {
|
||||||
|
const show_description = (flow && !!flow.show_description) || false;
|
||||||
|
onChangeCellView(id, { show_description, display: 'single' });
|
||||||
|
}, [id, flow, onChangeCellView]);
|
||||||
|
|
||||||
|
const setViewHorizontal = useCallback(() => {
|
||||||
|
const show_description = (flow && !!flow.show_description) || false;
|
||||||
|
onChangeCellView(id, { show_description, display: 'horizontal' });
|
||||||
|
}, [id, flow, onChangeCellView]);
|
||||||
|
|
||||||
|
const setViewVertical = useCallback(() => {
|
||||||
|
const show_description = (flow && !!flow.show_description) || false;
|
||||||
|
onChangeCellView(id, { show_description, display: 'vertical' });
|
||||||
|
}, [id, flow, onChangeCellView]);
|
||||||
|
|
||||||
|
const setViewQuadro = useCallback(() => {
|
||||||
|
const show_description = (flow && !!flow.show_description) || false;
|
||||||
|
onChangeCellView(id, { show_description, display: 'quadro' });
|
||||||
|
}, [id, flow, onChangeCellView]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.cell, 'vert-1', 'hor-1', { is_text: false })}
|
className={classNames(styles.cell, styles[(flow && flow.display) || 'single'], {
|
||||||
onClick={onClick}
|
[styles.is_text]: false,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div className={styles.face}>{title && <div className={styles.title}>{title}</div>}</div>
|
{can_edit && (
|
||||||
|
<div className={styles.menu}>
|
||||||
|
<div className={styles.menu_button}>
|
||||||
|
<Icon icon="dots-vertical" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{brief && brief.thumbnail && (
|
<div className={styles.menu_content}>
|
||||||
|
<Icon icon="text" onClick={toggleViewDescription} />
|
||||||
|
<div className={styles.menu_sep} />
|
||||||
|
<Icon icon="cell-single" onClick={setViewSingle} />
|
||||||
|
<Icon icon="cell-double-h" onClick={setViewHorizontal} />
|
||||||
|
<Icon icon="cell-double-v" onClick={setViewVertical} />
|
||||||
|
<Icon icon="cell-quadro" onClick={setViewQuadro} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classNames(styles.face, { [styles.has_text]: text })}>
|
||||||
|
<div className={styles.face_content}>
|
||||||
|
{title && <div className={styles.title}>{title}</div>}
|
||||||
|
{text && <div className={styles.text}>{text}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{thumbnail && (
|
||||||
<div
|
<div
|
||||||
className={styles.thumbnail}
|
className={styles.thumbnail}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url("${getURL({ url: brief.thumbnail })}")`,
|
backgroundImage: `url("${getURL({ url: thumbnail })}")`,
|
||||||
opacity: is_loaded ? 1 : 0,
|
opacity: is_loaded ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<img src={getURL({ url: brief.thumbnail })} onLoad={onImageLoad} alt="" />
|
<img src={getURL({ url: thumbnail })} onLoad={onImageLoad} alt="" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,17 +6,16 @@
|
||||||
background: $cell_bg;
|
background: $cell_bg;
|
||||||
border-radius: $cell_radius;
|
border-radius: $cell_radius;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:global(.is_hero) {
|
.is_hero {
|
||||||
.title {
|
.title {
|
||||||
font: $font_hero_title;
|
font: $font_hero_title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:global(.is_text) {
|
.is_text {
|
||||||
.title {
|
.title {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -26,16 +25,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
font: $font_16_regular;
|
font: $font_18_regular;
|
||||||
line-height: 1.3em;
|
line-height: 22px;
|
||||||
|
margin-top: $gap;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
padding: $gap;
|
|
||||||
background: darken($content_bg, 4%);
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
|
@ -43,55 +36,63 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
height: 160px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
background: linear-gradient(transparentize($content_bg, 1), $content_bg 70px);
|
background: linear-gradient(transparentize($content_bg, 1), $content_bg 95%);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
border-radius: 0 0 $radius $radius;
|
border-radius: 0 0 $radius $radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $cell * 2 + $grid_line) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title,
|
.title,
|
||||||
.text_title {
|
.text_title {
|
||||||
font: $font_cell_title;
|
font: $font_cell_title;
|
||||||
|
line-height: 1.1em;
|
||||||
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
max-height: 2.6em;
|
// max-height: 3.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text_title {
|
.text_title {
|
||||||
margin-bottom: $gap / 2;
|
margin-bottom: $gap / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global {
|
.horizontal,
|
||||||
.vert-1 {
|
.quadro {
|
||||||
|
grid-column-end: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical,
|
||||||
|
.quadro {
|
||||||
|
grid-row-end: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $cell * 2) {
|
||||||
|
.horizontal,
|
||||||
|
.quadro,
|
||||||
|
.vertical,
|
||||||
|
.quadro {
|
||||||
grid-row-end: span 1;
|
grid-row-end: span 1;
|
||||||
}
|
|
||||||
|
|
||||||
.vert-2 {
|
|
||||||
grid-row-end: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hor-1 {
|
|
||||||
grid-column-end: span 1;
|
grid-column-end: span 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hor-2 {
|
.is_text {
|
||||||
grid-column-end: span 2;
|
background: none;
|
||||||
}
|
padding: 10px;
|
||||||
|
box-shadow: inset #444 0 0 0 1px;
|
||||||
.is_text {
|
|
||||||
background: none;
|
|
||||||
padding: 10px;
|
|
||||||
box-shadow: inset #444 0 0 0 1px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
|
@ -106,6 +107,7 @@
|
||||||
border-radius: $cell_radius + 2px;
|
border-radius: $cell_radius + 2px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
& > img {
|
& > img {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
@ -115,6 +117,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.face {
|
.face {
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -125,4 +131,154 @@
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
border-radius: $cell_radius;
|
border-radius: $cell_radius;
|
||||||
padding: $gap;
|
padding: $gap;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
@media (min-width: $cell * 2 + $grid_line) {
|
||||||
|
.vertical > &.has_text,
|
||||||
|
.horizontal > &.has_text,
|
||||||
|
.quadro > &.has_text {
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: $grid_line;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face_content {
|
||||||
|
padding: $gap;
|
||||||
|
background: rgba(25, 25, 25, 0.8);
|
||||||
|
border-radius: $radius;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical > &.has_text {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
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;
|
||||||
|
height: 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
// height: auto;
|
||||||
|
width: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: -$gap;
|
||||||
|
right: -$gap;
|
||||||
|
z-index: 4;
|
||||||
|
border-radius: $radius;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $gap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
touch-action: auto;
|
||||||
|
|
||||||
|
.menu_content {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $cell * 2 + $grid_line) {
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu_button {
|
||||||
|
pointer-events: all;
|
||||||
|
touch-action: auto;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
width: 32px + $gap * 2;
|
||||||
|
height: 32px + $gap * 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.2;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: white;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu_content {
|
||||||
|
flex: 1;
|
||||||
|
opacity: 0;
|
||||||
|
background: $red_gradient;
|
||||||
|
padding: (32px + $gap * 2) $gap $gap $gap;
|
||||||
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
will-change: opacity;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin-top: $gap;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: #222222;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu_sep {
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
flex: 0 0 4px;
|
||||||
|
background-color: #222222;
|
||||||
|
opacity: 0.2;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,31 +4,34 @@ import { Cell } from '~/components/flow/Cell';
|
||||||
import * as styles from './styles.scss';
|
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 { IUser } from '~/redux/auth/types';
|
||||||
|
import { flowSetCellView } from '~/redux/flow/actions';
|
||||||
|
import { FlowHero } from '../FlowHero';
|
||||||
|
|
||||||
type IProps = Partial<IFlowState> & {
|
type IProps = Partial<IFlowState> & {
|
||||||
|
user: Partial<IUser>;
|
||||||
onSelect: (id: INode['id'], type: INode['type']) => void;
|
onSelect: (id: INode['id'], type: INode['type']) => void;
|
||||||
|
onChangeCellView: typeof flowSetCellView;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FlowGrid: FC<IProps> = ({ nodes, onSelect }) => (
|
export const FlowGrid: FC<IProps> = ({ user, nodes, heroes, onSelect, onChangeCellView }) => (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.grid_test}>
|
<div className={styles.grid_test}>
|
||||||
<div className={styles.hero}>HERO</div>
|
<div className={styles.hero}>
|
||||||
|
<FlowHero heroes={heroes} />
|
||||||
|
</div>
|
||||||
<div className={styles.stamp}>STAMP</div>
|
<div className={styles.stamp}>STAMP</div>
|
||||||
|
|
||||||
{nodes.map(node => (
|
{nodes.map(node => (
|
||||||
<Cell key={node.id} node={node} onSelect={onSelect} />
|
<Cell
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
onSelect={onSelect}
|
||||||
|
can_edit={canEditNode(node, user)}
|
||||||
|
onChangeCellView={onChangeCellView}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// {
|
|
||||||
// range(1, 20).map(el => (
|
|
||||||
// <Cell
|
|
||||||
// width={Math.floor(Math.random() * 2 + 1)}
|
|
||||||
// height={Math.floor(Math.random() * 2 + 1)}
|
|
||||||
// title={`Cell ${el}`}
|
|
||||||
// key={el}
|
|
||||||
// />
|
|
||||||
// ));
|
|
||||||
// }
|
|
||||||
|
|
|
@ -8,11 +8,47 @@ $cols: $content_width / $cell;
|
||||||
.grid_test {
|
.grid_test {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax($cell, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax($cell, 1fr));
|
||||||
grid-template-rows: $cell;
|
grid-template-rows: 50vh $cell;
|
||||||
grid-auto-rows: $cell;
|
grid-auto-rows: $cell;
|
||||||
grid-auto-flow: row dense;
|
grid-auto-flow: row dense;
|
||||||
grid-column-gap: $grid_line;
|
grid-column-gap: $grid_line;
|
||||||
grid-row-gap: $grid_line;
|
grid-row-gap: $grid_line;
|
||||||
|
|
||||||
|
@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(1, 1fr);
|
||||||
|
grid-template-rows: 40vh 75vw;
|
||||||
|
grid-auto-rows: 75vw;
|
||||||
|
grid-column-gap: $gap;
|
||||||
|
grid-row-gap: $gap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pad_last {
|
.pad_last {
|
||||||
|
|
117
src/components/flow/FlowHero/index.tsx
Normal file
117
src/components/flow/FlowHero/index.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import React, { FC, useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { IFlowState } from '~/redux/flow/reducer';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
import { withRouter, RouteComponentProps } from 'react-router';
|
||||||
|
import { URLS } from '~/constants/urls';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
|
type IProps = RouteComponentProps & {
|
||||||
|
heroes: IFlowState['heroes'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FlowHeroUnconnected: FC<IProps> = ({ heroes, history }) => {
|
||||||
|
const [limit, setLimit] = useState(Math.min(heroes.length, 6));
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [loaded, setLoaded] = useState([]);
|
||||||
|
const timer = useRef(null);
|
||||||
|
|
||||||
|
const onLoad = useCallback(id => () => setLoaded([...loaded, id]), [setLoaded, loaded]);
|
||||||
|
|
||||||
|
const onNext = useCallback(() => {
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
|
||||||
|
if (loaded.length <= 1) return;
|
||||||
|
|
||||||
|
const index = loaded.findIndex(el => el === current);
|
||||||
|
|
||||||
|
setCurrent(index > loaded.length - 2 ? loaded[0] : loaded[index + 1]);
|
||||||
|
}, [loaded, current, setCurrent, timer]);
|
||||||
|
|
||||||
|
const onNextPress = useCallback(() => {
|
||||||
|
setLimit(Math.min(heroes.length, limit + 1));
|
||||||
|
onNext();
|
||||||
|
}, [onNext, heroes, limit, setLimit]);
|
||||||
|
|
||||||
|
const onPrevious = useCallback(() => {
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
|
||||||
|
if (loaded.length <= 1) return;
|
||||||
|
|
||||||
|
const index = loaded.findIndex(el => el === current);
|
||||||
|
|
||||||
|
setCurrent(index > 0 ? loaded[index - 1] : loaded[loaded.length - 1]);
|
||||||
|
}, [loaded, current, setCurrent, timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timer.current = setTimeout(onNext, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer.current);
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (current === 0 && loaded.length > 0) setCurrent(loaded[0]);
|
||||||
|
}, [loaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLimit(limit > 0 ? Math.min(heroes.length, limit) : heroes.length);
|
||||||
|
}, [heroes, limit]);
|
||||||
|
|
||||||
|
const stopSliding = useCallback(() => {
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
timer.current = setTimeout(onNext, 5000);
|
||||||
|
}, [timer, onNext]);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
history.push(URLS.NODE_URL(current));
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log({ limit });
|
||||||
|
}, [limit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap} onMouseOver={stopSliding} onFocus={stopSliding}>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.title_wrap}>
|
||||||
|
<div className={styles.title}>TITLE!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<div className={styles.button} onClick={onPrevious}>
|
||||||
|
<Icon icon="left" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.button} onClick={onNextPress}>
|
||||||
|
<Icon icon="right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{heroes.slice(0, limit).map(hero => (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.hero, {
|
||||||
|
[styles.is_visible]: loaded.includes(hero.id),
|
||||||
|
[styles.is_active]: current === hero.id,
|
||||||
|
})}
|
||||||
|
style={{ backgroundImage: `url("${getURL({ url: hero.thumbnail })}")` }}
|
||||||
|
key={hero.id}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getURL({ url: hero.thumbnail })}
|
||||||
|
alt={hero.thumbnail}
|
||||||
|
onLoad={onLoad(hero.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FlowHero = withRouter(FlowHeroUnconnected);
|
||||||
|
|
||||||
|
export { FlowHero };
|
124
src/components/flow/FlowHero/styles.scss
Normal file
124
src/components/flow/FlowHero/styles.scss
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
// @keyframes rise {
|
||||||
|
// 0% {
|
||||||
|
// transform: translate(0, 0);
|
||||||
|
// }
|
||||||
|
// 100% {
|
||||||
|
// transform: translate(0, -10%);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: $content_bg;
|
||||||
|
border-radius: $cell_radius;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: ' ';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: url('~/sprites/stripes.svg') rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 150%;
|
||||||
|
display: none;
|
||||||
|
transition: opacity 2s, transform linear 5s 2s;
|
||||||
|
background: 50% 50% no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
border-radius: $cell_radius;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translate(0, 0);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is_visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is_active {
|
||||||
|
opacity: 1;
|
||||||
|
z-index: 3;
|
||||||
|
will-change: transform;
|
||||||
|
// animation: rise 5s forwards;
|
||||||
|
transform: translate(0, -10%);
|
||||||
|
transition: opacity 2s, transform linear 5s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: $gap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 5;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title_wrap {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
margin-right: $gap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 0;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 $gap;
|
||||||
|
border-radius: $radius;
|
||||||
|
font: $font_hero_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
flex-direction: row;
|
||||||
|
width: 96px;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,11 @@
|
||||||
.container {
|
.container {
|
||||||
height: 280px;
|
height: 280px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparentize(white, 0.9) url("http://37.192.131.144/hero/photos/photo-20140527-1639766.jpg") no-repeat 50% 30%;
|
background: transparentize(white, 0.9)
|
||||||
|
url('http://37.192.131.144/hero/photos/photo-20140527-1639766.jpg') no-repeat 50% 30%;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
will-change: transform;
|
||||||
//box-shadow: white 0 0 0 1px;
|
//box-shadow: white 0 0 0 1px;
|
||||||
//border-radius: $panel_radius $panel_radius 0 0;
|
//border-radius: $panel_radius $panel_radius 0 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import React, { ButtonHTMLAttributes, DetailedHTMLProps, FC, createElement } from 'react';
|
import React, { ButtonHTMLAttributes, DetailedHTMLProps, FC, createElement, memo } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Icon } from '~/components/input/Icon';
|
import { Icon } from '~/components/input/Icon';
|
||||||
import { IIcon } from '~/redux/types';
|
import { IIcon } from '~/redux/types';
|
||||||
|
@ -22,44 +22,48 @@ type IButtonProps = DetailedHTMLProps<
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Button: FC<IButtonProps> = ({
|
const Button: FC<IButtonProps> = memo(
|
||||||
className = '',
|
({
|
||||||
size = 'normal',
|
className = '',
|
||||||
iconLeft,
|
size = 'normal',
|
||||||
iconRight,
|
iconLeft,
|
||||||
children,
|
iconRight,
|
||||||
seamless = false,
|
children,
|
||||||
transparent = false,
|
seamless = false,
|
||||||
non_submitting = false,
|
transparent = false,
|
||||||
red = false,
|
non_submitting = false,
|
||||||
grey = false,
|
red = false,
|
||||||
is_loading,
|
grey = false,
|
||||||
title,
|
is_loading,
|
||||||
stretchy,
|
title,
|
||||||
disabled,
|
stretchy,
|
||||||
iconOnly,
|
disabled,
|
||||||
...props
|
iconOnly,
|
||||||
}) =>
|
...props
|
||||||
createElement(
|
}) =>
|
||||||
seamless || non_submitting ? 'div' : 'button',
|
createElement(
|
||||||
{
|
seamless || non_submitting ? 'div' : 'button',
|
||||||
className: classnames(styles.button, className, styles[size], {
|
{
|
||||||
red,
|
className: classnames(styles.button, className, styles[size], {
|
||||||
grey,
|
red,
|
||||||
seamless,
|
grey,
|
||||||
transparent,
|
seamless,
|
||||||
disabled,
|
transparent,
|
||||||
is_loading,
|
disabled,
|
||||||
stretchy,
|
is_loading,
|
||||||
icon: ((iconLeft || iconRight) && !title && !children) || iconOnly,
|
stretchy,
|
||||||
has_icon_left: !!iconLeft,
|
icon: ((iconLeft || iconRight) && !title && !children) || iconOnly,
|
||||||
has_icon_right: !!iconRight,
|
has_icon_left: !!iconLeft,
|
||||||
}),
|
has_icon_right: !!iconRight,
|
||||||
...props,
|
}),
|
||||||
},
|
...props,
|
||||||
[
|
},
|
||||||
iconLeft && <Icon icon={iconLeft} size={20} key={0} />,
|
[
|
||||||
title ? <span>{title}</span> : children || null,
|
iconLeft && <Icon icon={iconLeft} size={20} key={0} />,
|
||||||
iconRight && <Icon icon={iconRight} size={20} key={2} />,
|
title ? <span>{title}</span> : children || null,
|
||||||
]
|
iconRight && <Icon icon={iconRight} size={20} key={2} />,
|
||||||
);
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Button };
|
||||||
|
|
|
@ -108,6 +108,7 @@
|
||||||
&:global(.disabled),
|
&:global(.disabled),
|
||||||
&:global(.grey) {
|
&:global(.grey) {
|
||||||
background: transparentize(white, 0.9);
|
background: transparentize(white, 0.9);
|
||||||
|
color: white;
|
||||||
// background: lighten(white, 0.5);
|
// background: lighten(white, 0.5);
|
||||||
// filter: grayscale(100%);
|
// filter: grayscale(100%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
import React, {
|
import React, { FC, ChangeEvent, useCallback, useState, useEffect } from 'react';
|
||||||
FC,
|
|
||||||
ChangeEvent,
|
|
||||||
useCallback,
|
|
||||||
useState, useEffect,
|
|
||||||
} from 'react';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as styles from '~/styles/inputs.scss';
|
import * as styles from '~/styles/inputs.scss';
|
||||||
import { Icon } from '~/components/input/Icon';
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
@ -28,7 +23,7 @@ const InputText: FC<IInputTextProps> = ({
|
||||||
|
|
||||||
const onInput = useCallback(
|
const onInput = useCallback(
|
||||||
({ target }: ChangeEvent<HTMLInputElement>) => handler(target.value),
|
({ target }: ChangeEvent<HTMLInputElement>) => handler(target.value),
|
||||||
[handler],
|
[handler]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFocus = useCallback(() => setFocused(true), []);
|
const onFocus = useCallback(() => setFocused(true), []);
|
||||||
|
@ -39,18 +34,15 @@ const InputText: FC<IInputTextProps> = ({
|
||||||
}, [inner_ref, onRef]);
|
}, [inner_ref, onRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(
|
<div
|
||||||
styles.input_text_wrapper,
|
className={classNames(styles.input_text_wrapper, wrapperClassName, {
|
||||||
wrapperClassName,
|
|
||||||
{
|
|
||||||
[styles.required]: required,
|
[styles.required]: required,
|
||||||
[styles.focused]: focused,
|
[styles.focused]: focused,
|
||||||
[styles.has_status]: !!status || !!error,
|
[styles.has_status]: !!status || !!error,
|
||||||
[styles.has_value]: !!value,
|
[styles.has_value]: !!value,
|
||||||
[styles.has_error]: !!error,
|
[styles.has_error]: !!error,
|
||||||
[styles.has_loader]: is_loading,
|
[styles.has_loader]: is_loading,
|
||||||
},
|
})}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className={styles.input}>
|
<div className={styles.input}>
|
||||||
<input
|
<input
|
||||||
|
@ -79,12 +71,16 @@ const InputText: FC<IInputTextProps> = ({
|
||||||
<LoaderCircle size={20} />
|
<LoaderCircle size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{
|
{title && (
|
||||||
title && <div className={styles.title}><span>{title}</span></div>
|
<div className={styles.title}>
|
||||||
}
|
<span>{title}</span>
|
||||||
{
|
</div>
|
||||||
error && <div className={styles.error}><span>{error}</span></div>
|
)}
|
||||||
}
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { FC, useCallback, memo } 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';
|
||||||
|
@ -12,9 +12,7 @@ import * as MODAL_ACTIONS from '~/redux/modal/actions';
|
||||||
import { DIALOGS } from '~/redux/modal/constants';
|
import { DIALOGS } from '~/redux/modal/constants';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import { Icon } from '~/components/input/Icon';
|
import { Icon } from '~/components/input/Icon';
|
||||||
import { url } from 'inspector';
|
|
||||||
import { getURL } from '~/utils/dom';
|
import { getURL } from '~/utils/dom';
|
||||||
import path from 'ramda/es/path';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
user: pick(['username', 'is_user', 'photo'])(selectUser(state)),
|
user: pick(['username', 'is_user', 'photo'])(selectUser(state)),
|
||||||
|
@ -27,9 +25,8 @@ const mapDispatchToProps = {
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
const HeaderUnconnected: FC<IProps> = ({ user: { username, is_user, photo }, showDialog }) => {
|
const HeaderUnconnected: FC<IProps> = memo(({ user: { username, is_user, photo }, showDialog }) => {
|
||||||
const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]);
|
const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]);
|
||||||
const onOpenEditor = useCallback(() => showDialog(DIALOGS.EDITOR), [showDialog]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.container}>
|
<div className={style.container}>
|
||||||
|
@ -38,7 +35,6 @@ const HeaderUnconnected: FC<IProps> = ({ user: { username, is_user, photo }, sho
|
||||||
<Filler />
|
<Filler />
|
||||||
|
|
||||||
<div className={style.plugs}>
|
<div className={style.plugs}>
|
||||||
<div onClick={onOpenEditor}>editor</div>
|
|
||||||
<Link to="/">flow</Link>
|
<Link to="/">flow</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -58,7 +54,7 @@ const HeaderUnconnected: FC<IProps> = ({ user: { username, is_user, photo }, sho
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const Header = connect(
|
const Header = connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useState, useEffect } from 'react';
|
import React, { useCallback, useState, useEffect, memo } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { selectPlayer } from '~/redux/player/selectors';
|
import { selectPlayer } from '~/redux/player/selectors';
|
||||||
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
||||||
|
@ -14,7 +14,7 @@ const mapStateToProps = state => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
playerSetFile: PLAYER_ACTIONS.playerSetFile,
|
playerSetFileAndPlay: PLAYER_ACTIONS.playerSetFileAndPlay,
|
||||||
playerPlay: PLAYER_ACTIONS.playerPlay,
|
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||||
playerPause: PLAYER_ACTIONS.playerPause,
|
playerPause: PLAYER_ACTIONS.playerPause,
|
||||||
playerSeek: PLAYER_ACTIONS.playerSeek,
|
playerSeek: PLAYER_ACTIONS.playerSeek,
|
||||||
|
@ -25,75 +25,85 @@ type Props = ReturnType<typeof mapStateToProps> &
|
||||||
file: IFile;
|
file: IFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AudioPlayerUnconnected = ({
|
const AudioPlayerUnconnected = memo(
|
||||||
file,
|
({
|
||||||
player: { file: current, status },
|
file,
|
||||||
|
player: { file: current, status },
|
||||||
|
playerSetFileAndPlay,
|
||||||
|
playerPlay,
|
||||||
|
playerPause,
|
||||||
|
playerSeek,
|
||||||
|
}: Props) => {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<IPlayerProgress>({
|
||||||
|
progress: 0,
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
playerSetFile,
|
const onPlay = useCallback(() => {
|
||||||
playerPlay,
|
if (current && current.id === file.id) {
|
||||||
playerPause,
|
if (status === PLAYER_STATES.PLAYING) return playerPause();
|
||||||
playerSeek,
|
return playerPlay();
|
||||||
}: Props) => {
|
}
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [progress, setProgress] = useState<IPlayerProgress>({ progress: 0, current: 0, total: 0 });
|
|
||||||
|
|
||||||
const onPlay = useCallback(() => {
|
playerSetFileAndPlay(file);
|
||||||
if (current && current.id === file.id) {
|
}, [file, current, status, playerPlay, playerPause, playerSetFileAndPlay]);
|
||||||
if (status === PLAYER_STATES.PLAYING) return playerPause();
|
|
||||||
return playerPlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
playerSetFile(file);
|
const onProgress = useCallback(
|
||||||
}, [file, current, status, playerPlay, playerPause, playerSetFile]);
|
({ detail }: { detail: IPlayerProgress }) => {
|
||||||
|
if (!detail || !detail.total) return;
|
||||||
|
setProgress(detail);
|
||||||
|
},
|
||||||
|
[setProgress]
|
||||||
|
);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onSeek = useCallback(
|
||||||
({ detail }: { detail: IPlayerProgress }) => {
|
event => {
|
||||||
if (!detail || !detail.total) return;
|
event.stopPropagation();
|
||||||
setProgress(detail);
|
const { clientX, target } = event;
|
||||||
},
|
const { left, width } = target.getBoundingClientRect();
|
||||||
[setProgress]
|
playerSeek((clientX - left) / width);
|
||||||
);
|
},
|
||||||
|
[playerSeek]
|
||||||
|
);
|
||||||
|
|
||||||
const onSeek = useCallback(
|
useEffect(() => {
|
||||||
event => {
|
const active = current && current.id === file.id;
|
||||||
event.stopPropagation();
|
setPlaying(current && current.id === file.id);
|
||||||
const { clientX, target } = event;
|
|
||||||
const { left, width } = target.getBoundingClientRect();
|
|
||||||
playerSeek((clientX - left) / width);
|
|
||||||
},
|
|
||||||
[playerSeek]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (active) Player.on('playprogress', onProgress);
|
||||||
const active = current && current.id === file.id;
|
|
||||||
setPlaying(current && current.id === file.id);
|
|
||||||
|
|
||||||
if (active) Player.on('playprogress', onProgress);
|
return () => {
|
||||||
|
if (active) Player.off('playprogress', onProgress);
|
||||||
|
};
|
||||||
|
}, [file, current, setPlaying, onProgress]);
|
||||||
|
|
||||||
return () => {
|
const title =
|
||||||
if (active) Player.off('playprogress', onProgress);
|
file.metadata &&
|
||||||
};
|
(file.metadata.title ||
|
||||||
}, [file, current, setPlaying, onProgress]);
|
[file.metadata.id3artist, file.metadata.id3title].filter(el => !!el).join(' - '));
|
||||||
|
|
||||||
const title =
|
return (
|
||||||
file.metadata &&
|
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
|
||||||
(file.metadata.title ||
|
<div className={styles.playpause}>
|
||||||
[file.metadata.id3artist, file.metadata.id3title].filter(el => !!el).join(' - '));
|
{playing && status === PLAYER_STATES.PLAYING ? (
|
||||||
|
<Icon icon="pause" />
|
||||||
return (
|
) : (
|
||||||
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
|
<Icon icon="play" />
|
||||||
<div className={styles.playpause}>
|
)}
|
||||||
{playing && status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
|
</div>
|
||||||
</div>
|
<div className={styles.content}>
|
||||||
<div className={styles.content}>
|
<div className={styles.title}>{title || 'Unknown'}</div>
|
||||||
<div className={styles.progress} onClick={onSeek}>
|
|
||||||
<div className={styles.bar} style={{ width: `${progress.progress}%` }} />
|
<div className={styles.progress} onClick={onSeek}>
|
||||||
|
<div className={styles.bar} style={{ width: `${progress.progress}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.title}>{title || 'Unknown'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
};
|
);
|
||||||
|
|
||||||
export const AudioPlayer = connect(
|
export const AudioPlayer = connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
height: $comment_height;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
&:global(.playing) {
|
&:global(.playing) {
|
||||||
.progress {
|
.progress {
|
||||||
|
@ -93,7 +98,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
background: linear-gradient(270deg, $green, $wisegreen);
|
// background: linear-gradient(270deg, $green, $wisegreen);
|
||||||
|
background: $main_gradient;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
|
@ -1,75 +1,36 @@
|
||||||
import React, { FC, HTMLAttributes, useMemo } from 'react';
|
import React, { FC, HTMLAttributes, memo } from 'react';
|
||||||
import { CommentWrapper } from '~/components/containers/CommentWrapper';
|
import { CommentWrapper } from '~/components/containers/CommentWrapper';
|
||||||
import { IComment, IFile } from '~/redux/types';
|
import { ICommentGroup } from '~/redux/types';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
import { CommentContent } from '~/components/node/CommentContent';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
|
|
||||||
import { Group } from '~/components/containers/Group';
|
|
||||||
import assocPath from 'ramda/es/assocPath';
|
|
||||||
import append from 'ramda/es/append';
|
|
||||||
import reduce from 'ramda/es/reduce';
|
|
||||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
|
||||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
is_empty?: boolean;
|
is_empty?: boolean;
|
||||||
is_loading?: boolean;
|
is_loading?: boolean;
|
||||||
comment?: IComment;
|
comment_group?: ICommentGroup;
|
||||||
is_same?: boolean;
|
is_same?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Comment: FC<IProps> = ({ comment, is_empty, is_same, is_loading, className, ...props }) => {
|
const Comment: FC<IProps> = memo(
|
||||||
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
({ comment_group, is_empty, is_same, is_loading, className, ...props }) => {
|
||||||
() =>
|
return (
|
||||||
reduce(
|
<CommentWrapper
|
||||||
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
className={className}
|
||||||
{},
|
is_empty={is_empty}
|
||||||
comment.files
|
is_loading={is_loading}
|
||||||
),
|
user={comment_group.user}
|
||||||
[comment]
|
is_same={is_same}
|
||||||
);
|
{...props}
|
||||||
|
>
|
||||||
return (
|
<div className={styles.wrap}>
|
||||||
<CommentWrapper
|
{comment_group.comments.map(comment => (
|
||||||
className={className}
|
<CommentContent comment={comment} key={comment.id} />
|
||||||
is_empty={is_empty}
|
|
||||||
is_loading={is_loading}
|
|
||||||
photo={getURL(comment.user.photo)}
|
|
||||||
is_same={is_same}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{comment.text && (
|
|
||||||
<Group
|
|
||||||
className={styles.text}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: formatCommentText(
|
|
||||||
!is_same && comment.user && comment.user.username,
|
|
||||||
comment.text
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
|
||||||
|
|
||||||
{groupped.image && (
|
|
||||||
<div className={styles.images}>
|
|
||||||
{groupped.image.map(file => (
|
|
||||||
<div key={file.id}>
|
|
||||||
<img src={getURL(file)} alt={file.name} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CommentWrapper>
|
||||||
|
);
|
||||||
{groupped.audio && (
|
}
|
||||||
<div className={styles.audios}>
|
);
|
||||||
{groupped.audio.map(file => (
|
|
||||||
<AudioPlayer key={file.id} file={file} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CommentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Comment };
|
export { Comment };
|
||||||
|
|
|
@ -1,48 +1,11 @@
|
||||||
@import 'flexbin/flexbin.scss';
|
@keyframes appear {
|
||||||
|
from {
|
||||||
.text {
|
opacity: 0;
|
||||||
// @include outer_shadow();
|
}
|
||||||
|
to {
|
||||||
padding: $gap;
|
opacity: 1;
|
||||||
font-weight: 300;
|
|
||||||
font: $font_16_medium;
|
|
||||||
min-height: $comment_height;
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: relative;
|
|
||||||
color: #cccccc;
|
|
||||||
|
|
||||||
b {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.wrap {
|
||||||
.date {
|
animation: appear 1s;
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
font: $font_12_regular;
|
|
||||||
color: transparentize($color: white, $amount: 0.8);
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 0 0 $radius 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.images {
|
|
||||||
@include flexbin(240px, 5px);
|
|
||||||
|
|
||||||
img {
|
|
||||||
border-radius: $radius;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.audios {
|
|
||||||
& > div {
|
|
||||||
@include outer_shadow();
|
|
||||||
|
|
||||||
height: $comment_height;
|
|
||||||
border-radius: $radius;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
84
src/components/node/CommentContent/index.tsx
Normal file
84
src/components/node/CommentContent/index.tsx
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import React, { FC, useMemo, memo } from 'react';
|
||||||
|
import { IComment, IFile } from '~/redux/types';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
|
||||||
|
import { Group } from '~/components/containers/Group';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import assocPath from 'ramda/es/assocPath';
|
||||||
|
import append from 'ramda/es/append';
|
||||||
|
import reduce from 'ramda/es/reduce';
|
||||||
|
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
comment: IComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommentContent: FC<IProps> = memo(({ comment }) => {
|
||||||
|
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||||
|
() =>
|
||||||
|
reduce(
|
||||||
|
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
||||||
|
{},
|
||||||
|
comment.files
|
||||||
|
),
|
||||||
|
[comment]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{comment.text && (
|
||||||
|
<div className={styles.block}>
|
||||||
|
<Group
|
||||||
|
className={styles.text}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: formatCommentText(path(['user', 'username'], comment), comment.text),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupped.image && groupped.image.length > 0 && (
|
||||||
|
<div className={classnames(styles.block, styles.block_image)}>
|
||||||
|
<div className={styles.images}>
|
||||||
|
{groupped.image.map(file => (
|
||||||
|
<div key={file.id}>
|
||||||
|
<img src={getURL(file)} alt={file.name} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupped.audio && groupped.audio.length > 0 && (
|
||||||
|
<>
|
||||||
|
{groupped.audio.map(file => (
|
||||||
|
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
|
||||||
|
<AudioPlayer file={file} />
|
||||||
|
|
||||||
|
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CommentContent };
|
||||||
|
|
||||||
|
/*
|
||||||
|
{comment.text && (
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
80
src/components/node/CommentContent/styles.scss
Normal file
80
src/components/node/CommentContent/styles.scss
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
@import 'flexbin/flexbin.scss';
|
||||||
|
|
||||||
|
.block {
|
||||||
|
@include outer_shadow();
|
||||||
|
min-height: $comment_height;
|
||||||
|
// box-shadow: inset rgba(255, 255, 255, 0.05) 1px 1px, inset rgba(0, 0, 0, 0.1) -1px -1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-right-radius: $radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-right-radius: $radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.block_audio {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block_image {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
|
||||||
|
.date {
|
||||||
|
background: transparentize($color: $content_bg, $amount: 0.2);
|
||||||
|
border-radius: $radius 0 $radius 0;
|
||||||
|
color: transparentize(white, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
padding: $gap;
|
||||||
|
font-weight: 300;
|
||||||
|
font: $font_16_medium;
|
||||||
|
line-height: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
color: #cccccc;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
font: $font_12_regular;
|
||||||
|
color: transparentize($color: white, $amount: 0.8);
|
||||||
|
padding: 4px 6px 4px 6px;
|
||||||
|
border-radius: 0 0 $radius 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.images {
|
||||||
|
@include flexbin(240px, 5px);
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: $radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.audios {
|
||||||
|
& > div {
|
||||||
|
height: $comment_height;
|
||||||
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,7 +42,7 @@ type IProps = ReturnType<typeof mapStateToProps> &
|
||||||
const CommentFormUnconnected: FC<IProps> = ({
|
const CommentFormUnconnected: FC<IProps> = ({
|
||||||
node: { comment_data, is_sending_comment },
|
node: { comment_data, is_sending_comment },
|
||||||
uploads: { statuses, files },
|
uploads: { statuses, files },
|
||||||
user: { photo },
|
user,
|
||||||
id,
|
id,
|
||||||
nodePostComment,
|
nodePostComment,
|
||||||
nodeSetCommentData,
|
nodeSetCommentData,
|
||||||
|
@ -122,7 +122,7 @@ const CommentFormUnconnected: FC<IProps> = ({
|
||||||
const comment = comment_data[id];
|
const comment = comment_data[id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommentWrapper photo={getURL(photo)}>
|
<CommentWrapper user={user}>
|
||||||
<form onSubmit={onSubmit} className={styles.wrap}>
|
<form onSubmit={onSubmit} className={styles.wrap}>
|
||||||
<div className={styles.input}>
|
<div className={styles.input}>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
@ -134,6 +134,22 @@ const CommentFormUnconnected: FC<IProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{comment.temp_ids.map(
|
||||||
|
temp_id =>
|
||||||
|
statuses[temp_id] &&
|
||||||
|
statuses[temp_id].is_uploading && (
|
||||||
|
<div key={statuses[temp_id].temp_id}>{statuses[temp_id].progress}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comment.files.map(file => {
|
||||||
|
if (file.type === UPLOAD_TYPES.AUDIO) {
|
||||||
|
return <AudioPlayer file={file} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>file.name</div>;
|
||||||
|
})}
|
||||||
|
|
||||||
<Group horizontal className={styles.buttons}>
|
<Group horizontal className={styles.buttons}>
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Button iconLeft="image" size="small" grey iconOnly>
|
<Button iconLeft="image" size="small" grey iconOnly>
|
||||||
|
@ -154,22 +170,6 @@ const CommentFormUnconnected: FC<IProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{comment.temp_ids.map(
|
|
||||||
temp_id =>
|
|
||||||
statuses[temp_id] &&
|
|
||||||
statuses[temp_id].is_uploading && (
|
|
||||||
<div key={statuses[temp_id].temp_id}>{statuses[temp_id].progress}</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{comment.files.map(file => {
|
|
||||||
if (file.type === UPLOAD_TYPES.AUDIO) {
|
|
||||||
return <AudioPlayer file={file} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div>file.name</div>;
|
|
||||||
})}
|
|
||||||
</CommentWrapper>
|
</CommentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
26
src/components/node/NodeAudioBlock/index.tsx
Normal file
26
src/components/node/NodeAudioBlock/index.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
node: INode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeAudioBlock: FC<IProps> = ({ node }) => {
|
||||||
|
const audios = useMemo(
|
||||||
|
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
|
||||||
|
[node.files]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{audios.map(file => (
|
||||||
|
<AudioPlayer key={file.id} file={file} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NodeAudioBlock };
|
17
src/components/node/NodeAudioBlock/styles.scss
Normal file
17
src/components/node/NodeAudioBlock/styles.scss
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
.wrap {
|
||||||
|
background: $content_bg;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: $radius;
|
||||||
|
border-top-right-radius: $radius;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-left-radius: $radius;
|
||||||
|
border-bottom-right-radius: $radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
src/components/node/NodeAudioImageBlock/index.tsx
Normal file
28
src/components/node/NodeAudioImageBlock/index.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
node: INode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
|
||||||
|
const images = useMemo(
|
||||||
|
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
|
||||||
|
[node.files]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div
|
||||||
|
className={styles.slide}
|
||||||
|
style={{ backgroundImage: `url("${getURL(path([0], images))}")` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NodeAudioImageBlock };
|
33
src/components/node/NodeAudioImageBlock/styles.scss
Normal file
33
src/components/node/NodeAudioImageBlock/styles.scss
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
.wrap {
|
||||||
|
@include outer_shadow();
|
||||||
|
padding-bottom: 33vh;
|
||||||
|
position: relative;
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
content: ' ';
|
||||||
|
z-index: 3;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5) url('~/sprites/dots.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: no-repeat 50% 30%;
|
||||||
|
background-size: cover;
|
||||||
|
z-index: 1;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
|
@ -1,24 +1,29 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, useMemo, memo } from 'react';
|
||||||
import { Comment } from '../Comment';
|
import { Comment } from '../Comment';
|
||||||
import { Filler } from '~/components/containers/Filler';
|
import { Filler } from '~/components/containers/Filler';
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
import { ICommentGroup, IComment } from '~/redux/types';
|
||||||
|
import { groupCommentsByUser } from '~/utils/fn';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
comments?: any;
|
comments?: IComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSameComment = (comments, index) =>
|
const NodeComments: FC<IProps> = memo(({ comments }) => {
|
||||||
comments[index - 1] && comments[index - 1].user.id === comments[index].user.id;
|
const groupped: ICommentGroup[] = useMemo(() => comments.reduce(groupCommentsByUser, []), [
|
||||||
|
comments,
|
||||||
|
]);
|
||||||
|
|
||||||
const NodeComments: FC<IProps> = ({ comments }) => (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
{comments.map((comment, index) => (
|
{groupped.map(group => (
|
||||||
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
|
<Comment key={group.ids.join()} comment_group={group} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Filler />
|
<Filler />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export { NodeComments };
|
export { NodeComments };
|
||||||
|
|
|
@ -1,20 +1,9 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
& > div {
|
& > div {
|
||||||
margin: $gap 0 0 0;
|
margin: 0 0 $gap 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// display: flex;
|
|
||||||
// flex-direction: column !important;
|
|
||||||
|
|
||||||
// & > div {
|
|
||||||
// margin: ($gap / 2) 0;
|
|
||||||
|
|
||||||
// &:last-child {
|
|
||||||
// margin-top: 0;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// &:first-child {
|
|
||||||
// margin-bottom: 0;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
217
src/components/node/NodeImageSlideBlock/index.tsx
Normal file
217
src/components/node/NodeImageSlideBlock/index.tsx
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { ImageSwitcher } from '../ImageSwitcher';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { getImageSize } from '~/utils/dom';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
import { NODE_SETTINGS } from '~/redux/node/constants';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
is_loading: boolean;
|
||||||
|
node: INode;
|
||||||
|
layout: {};
|
||||||
|
updateLayout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getX = event => (event.touches ? event.touches[0].clientX : event.clientX);
|
||||||
|
|
||||||
|
const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) => {
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [height, setHeight] = useState(320);
|
||||||
|
const [max_height, setMaxHeight] = useState(960);
|
||||||
|
const [loaded, setLoaded] = useState<Record<number, boolean>>({});
|
||||||
|
const refs = useRef<Record<number, HTMLDivElement>>({});
|
||||||
|
const [heights, setHeights] = useState({});
|
||||||
|
|
||||||
|
const [initial_offset, setInitialOffset] = useState(0);
|
||||||
|
const [initial_x, setInitialX] = useState(0);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [is_dragging, setIsDragging] = useState(false);
|
||||||
|
const slide = useRef<HTMLDivElement>();
|
||||||
|
const wrap = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
const images = useMemo(
|
||||||
|
() =>
|
||||||
|
(node && node.files && node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || [],
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSizes = useCallback(() => {
|
||||||
|
const values = Object.keys(refs.current).reduce((obj, key) => {
|
||||||
|
const ref = refs.current[key];
|
||||||
|
|
||||||
|
if (!ref || !ref.getBoundingClientRect) return 0;
|
||||||
|
|
||||||
|
return { ...obj, [key]: ref.getBoundingClientRect().height };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
setHeights(values);
|
||||||
|
}, [refs]);
|
||||||
|
|
||||||
|
const setRef = useCallback(
|
||||||
|
index => el => {
|
||||||
|
refs.current[index] = el;
|
||||||
|
},
|
||||||
|
[refs, heights, setHeights]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onImageLoad = useCallback(index => () => setLoaded({ ...loaded, [index]: true }), [
|
||||||
|
setLoaded,
|
||||||
|
loaded,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// update outside hooks
|
||||||
|
useEffect(() => updateLayout(), [loaded, height]);
|
||||||
|
useEffect(() => updateSizes(), [refs, current, loaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wrap || !wrap.current) return;
|
||||||
|
|
||||||
|
const { width } = wrap.current.getBoundingClientRect();
|
||||||
|
const selected = Math.abs(-offset / width);
|
||||||
|
const prev = Math.max(heights[Math.floor(selected)] || 320, 320);
|
||||||
|
const next = Math.max(heights[Math.ceil(selected)] || 320, 320);
|
||||||
|
const now = prev - (prev - next) * (selected % 1);
|
||||||
|
|
||||||
|
if (current !== Math.round(selected)) setCurrent(Math.round(selected));
|
||||||
|
|
||||||
|
setHeight(now);
|
||||||
|
}, [offset, heights, max_height]);
|
||||||
|
|
||||||
|
const onDrag = useCallback(
|
||||||
|
event => {
|
||||||
|
if (
|
||||||
|
!is_dragging ||
|
||||||
|
!slide.current ||
|
||||||
|
!wrap.current ||
|
||||||
|
(event.touches && event.clientY > event.clientX)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const { width: slide_width } = slide.current.getBoundingClientRect();
|
||||||
|
const { width: wrap_width } = wrap.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
setOffset(
|
||||||
|
Math.min(Math.max(initial_offset + getX(event) - initial_x, wrap_width - slide_width), 0)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[is_dragging, initial_x, setOffset, initial_offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizeOffset = useCallback(() => {
|
||||||
|
const { width: wrap_width } = wrap.current.getBoundingClientRect();
|
||||||
|
const { width: slide_width } = slide.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
const shift = (initial_offset - offset) / wrap_width; // percent / 100
|
||||||
|
const diff = initial_offset - (shift > 0 ? Math.ceil(shift) : Math.floor(shift)) * wrap_width;
|
||||||
|
const new_offset =
|
||||||
|
Math.abs(shift) > 0.25
|
||||||
|
? Math.min(Math.max(diff, wrap_width - slide_width), 0) // next or prev slide
|
||||||
|
: Math.round(offset / wrap_width) * wrap_width; // back to this one
|
||||||
|
|
||||||
|
setOffset(new_offset);
|
||||||
|
}, [wrap, offset, initial_offset]);
|
||||||
|
|
||||||
|
const updateMaxHeight = useCallback(() => {
|
||||||
|
if (!wrap.current) return;
|
||||||
|
const { width } = wrap.current.getBoundingClientRect();
|
||||||
|
setMaxHeight(width * NODE_SETTINGS.MAX_IMAGE_ASPECT);
|
||||||
|
normalizeOffset();
|
||||||
|
}, [wrap, setMaxHeight, normalizeOffset]);
|
||||||
|
|
||||||
|
const stopDragging = useCallback(() => {
|
||||||
|
if (!is_dragging) return;
|
||||||
|
|
||||||
|
normalizeOffset();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, [setIsDragging, is_dragging, normalizeOffset]);
|
||||||
|
|
||||||
|
const startDragging = useCallback(
|
||||||
|
event => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setInitialX(getX(event));
|
||||||
|
setInitialOffset(offset);
|
||||||
|
},
|
||||||
|
[setIsDragging, setInitialX, offset, setInitialOffset]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => updateMaxHeight(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', updateSizes);
|
||||||
|
window.addEventListener('resize', updateMaxHeight);
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onDrag);
|
||||||
|
window.addEventListener('touchmove', onDrag);
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', stopDragging);
|
||||||
|
window.addEventListener('touchend', stopDragging);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateSizes);
|
||||||
|
window.removeEventListener('resize', updateMaxHeight);
|
||||||
|
|
||||||
|
window.removeEventListener('mousemove', onDrag);
|
||||||
|
window.removeEventListener('touchmove', onDrag);
|
||||||
|
|
||||||
|
window.removeEventListener('mouseup', stopDragging);
|
||||||
|
window.removeEventListener('touchend', stopDragging);
|
||||||
|
};
|
||||||
|
}, [onDrag, stopDragging, updateMaxHeight, updateSizes]);
|
||||||
|
|
||||||
|
const changeCurrent = useCallback(
|
||||||
|
(item: number) => {
|
||||||
|
const { width } = wrap.current.getBoundingClientRect();
|
||||||
|
setOffset(-1 * item * width);
|
||||||
|
},
|
||||||
|
[wrap]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.wrap, { is_loading })} ref={wrap}>
|
||||||
|
<ImageSwitcher
|
||||||
|
total={images.length}
|
||||||
|
current={current}
|
||||||
|
onChange={changeCurrent}
|
||||||
|
loaded={loaded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.image_container}
|
||||||
|
style={{
|
||||||
|
transition: is_dragging ? 'none' : 'transform 500ms',
|
||||||
|
height,
|
||||||
|
transform: `translate(${offset}px, 0)`,
|
||||||
|
width: `${images.length * 100}%`,
|
||||||
|
}}
|
||||||
|
onMouseDown={startDragging}
|
||||||
|
onTouchStart={startDragging}
|
||||||
|
ref={slide}
|
||||||
|
>
|
||||||
|
{(is_loading || !loaded[0] || !images.length) && <div className={styles.placeholder} />}
|
||||||
|
|
||||||
|
{images.map((file, index) => (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.image_wrap, {
|
||||||
|
is_active: index === current && loaded[index],
|
||||||
|
})}
|
||||||
|
ref={setRef(index)}
|
||||||
|
key={file.id}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={styles.image}
|
||||||
|
src={getImageSize(file, 'node')}
|
||||||
|
alt=""
|
||||||
|
key={file.id}
|
||||||
|
onLoad={onImageLoad(index)}
|
||||||
|
style={{ maxHeight: max_height }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NodeImageSlideBlock };
|
49
src/components/node/NodeImageSlideBlock/styles.scss
Normal file
49
src/components/node/NodeImageSlideBlock/styles.scss
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
.wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
transition: height 0.25s;
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image_container {
|
||||||
|
background: $node_image_bg;
|
||||||
|
border-radius: $panel_radius 0 0 $panel_radius;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
will-change: transform, height;
|
||||||
|
|
||||||
|
.image {
|
||||||
|
max-height: 960px;
|
||||||
|
max-width: 100%;
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image_wrap {
|
||||||
|
width: 100%;
|
||||||
|
// top: 0;
|
||||||
|
// left: 0;
|
||||||
|
// opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:global(.is_active) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
background: red;
|
||||||
|
height: 320px;
|
||||||
|
}
|
|
@ -1,48 +1,79 @@
|
||||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { FC, useCallback, useEffect, useRef, useState, memo } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { INode } from '~/redux/types';
|
import { INode } from '~/redux/types';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { NodePanelInner } from '~/components/node/NodePanelInner';
|
import { NodePanelInner } from '~/components/node/NodePanelInner';
|
||||||
|
import pick from 'ramda/es/pick';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
node: INode;
|
node: Partial<INode>;
|
||||||
layout: {};
|
layout: {};
|
||||||
|
|
||||||
|
can_edit: boolean;
|
||||||
|
can_like: boolean;
|
||||||
|
can_star: boolean;
|
||||||
|
|
||||||
|
onEdit: () => void;
|
||||||
|
onLike: () => void;
|
||||||
|
onStar: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodePanel: FC<IProps> = ({ node, layout }) => {
|
const NodePanel: FC<IProps> = memo(
|
||||||
const [stack, setStack] = useState(false);
|
({ node, layout, can_edit, can_like, can_star, onEdit, onLike, onStar }) => {
|
||||||
|
const [stack, setStack] = useState(false);
|
||||||
|
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const getPlace = useCallback(() => {
|
const getPlace = useCallback(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
|
|
||||||
const { offsetTop } = ref.current;
|
const { offsetTop } = ref.current;
|
||||||
const { height } = ref.current.getBoundingClientRect();
|
const { height } = ref.current.getBoundingClientRect();
|
||||||
const { scrollY, innerHeight } = window;
|
const { scrollY, innerHeight } = window;
|
||||||
|
|
||||||
setStack(offsetTop > scrollY + innerHeight - height);
|
setStack(offsetTop > scrollY + innerHeight - height);
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPlace();
|
getPlace();
|
||||||
window.addEventListener('scroll', getPlace);
|
window.addEventListener('scroll', getPlace);
|
||||||
window.addEventListener('resize', getPlace);
|
window.addEventListener('resize', getPlace);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', getPlace);
|
window.removeEventListener('scroll', getPlace);
|
||||||
window.removeEventListener('resize', getPlace);
|
window.removeEventListener('resize', getPlace);
|
||||||
};
|
};
|
||||||
}, [layout]);
|
}, [layout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.place} ref={ref}>
|
<div className={styles.place} ref={ref}>
|
||||||
{stack ? (
|
{stack ? (
|
||||||
createPortal(<NodePanelInner node={node} stack />, document.body)
|
createPortal(
|
||||||
) : (
|
<NodePanelInner
|
||||||
<NodePanelInner node={node} />
|
node={node}
|
||||||
)}
|
stack
|
||||||
</div>
|
onEdit={onEdit}
|
||||||
);
|
onLike={onLike}
|
||||||
};
|
onStar={onStar}
|
||||||
|
can_edit={can_edit}
|
||||||
|
can_like={can_like}
|
||||||
|
can_star={can_star}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<NodePanelInner
|
||||||
|
node={node}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onLike={onLike}
|
||||||
|
onStar={onStar}
|
||||||
|
can_edit={can_edit}
|
||||||
|
can_like={can_like}
|
||||||
|
can_star={can_star}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export { NodePanel };
|
export { NodePanel };
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { FC } from 'react';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
import { Filler } from '~/components/containers/Filler';
|
import { Filler } from '~/components/containers/Filler';
|
||||||
|
@ -7,27 +7,61 @@ import { INode } from '~/redux/types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
node: INode;
|
node: Partial<INode>;
|
||||||
stack?: boolean;
|
stack?: boolean;
|
||||||
|
|
||||||
|
can_edit: boolean;
|
||||||
|
can_like: boolean;
|
||||||
|
can_star: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onLike: () => void;
|
||||||
|
onStar: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodePanelInner: FC<IProps> = ({ node: { title, user }, stack }) => {
|
const NodePanelInner: FC<IProps> = ({
|
||||||
|
node: { title, user, is_liked, is_heroic },
|
||||||
|
stack,
|
||||||
|
can_star,
|
||||||
|
can_edit,
|
||||||
|
can_like,
|
||||||
|
onStar,
|
||||||
|
onEdit,
|
||||||
|
onLike,
|
||||||
|
}) => {
|
||||||
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}>
|
<Group horizontal className={styles.panel}>
|
||||||
<Filler>
|
<Filler>
|
||||||
<div className={styles.title}>{title || '...'}</div>
|
<div className={styles.title}>{title || '...'}</div>
|
||||||
{user && user.username && <div className={styles.name}>~ {user.username}</div>}
|
{user && user.username && <div className={styles.name}>~{user.username}</div>}
|
||||||
</Filler>
|
</Filler>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
<Icon icon="edit" size={24} />
|
{can_star && (
|
||||||
|
<div className={classNames(styles.star, { is_heroic })}>
|
||||||
<div className={styles.sep} />
|
{is_heroic ? (
|
||||||
|
<Icon icon="star_full" size={24} onClick={onStar} />
|
||||||
<Icon icon="heart" size={24} />
|
) : (
|
||||||
|
<Icon icon="star" size={24} onClick={onStar} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{can_edit && (
|
||||||
|
<div>
|
||||||
|
<Icon icon="edit" size={24} onClick={onEdit} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{can_like && (
|
||||||
|
<div className={classNames(styles.like, { is_liked })}>
|
||||||
|
{is_liked ? (
|
||||||
|
<Icon icon="heart_full" size={24} onClick={onLike} />
|
||||||
|
) : (
|
||||||
|
<Icon icon="heart" size={24} onClick={onLike} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
padding: $gap;
|
padding: $gap;
|
||||||
background: $node_bg;
|
background: $node_bg;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
|
@include outer_shadow();
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
@ -65,22 +66,43 @@
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
margin: 0 $gap;
|
margin: 0 $gap;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: darken(white, 50%);
|
||||||
|
transition: fill 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
svg {
|
||||||
|
fill: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: ' ';
|
||||||
|
flex: 0 0 6px;
|
||||||
|
height: $gap;
|
||||||
|
width: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparentize(black, 0.7);
|
||||||
|
margin-left: $gap * 2;
|
||||||
|
}
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//height: 54px;
|
|
||||||
//border-radius: $radius $radius 0 0;
|
|
||||||
//background: linear-gradient(176deg, #f42a00, #5c1085);
|
|
||||||
//position: absolute;
|
|
||||||
//bottom: 0;
|
|
||||||
//right: 10px;
|
|
||||||
//width: 270px;
|
|
||||||
//display: flex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mark {
|
.mark {
|
||||||
|
@ -94,16 +116,68 @@
|
||||||
right: 4px;
|
right: 4px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
background: $green_gradient;
|
background: $main_gradient;
|
||||||
box-shadow: transparentize(black, 0.8) 4px 2px;
|
box-shadow: transparentize(black, 0.8) 4px 2px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sep {
|
.sep {
|
||||||
flex: 0 0 6px;
|
}
|
||||||
height: 6px;
|
|
||||||
width: 6px;
|
@keyframes pulse {
|
||||||
border-radius: 4px;
|
0% {
|
||||||
background: transparentize(black, 0.7);
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
45% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
90% {
|
||||||
|
transform: scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.like {
|
||||||
|
transition: fill, stroke 0.25s;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
|
&:global(.is_liked) {
|
||||||
|
svg {
|
||||||
|
fill: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
fill: $red;
|
||||||
|
animation: pulse 0.75s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
transition: fill, stroke 0.25s;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
|
&:global(.is_heroic) {
|
||||||
|
svg {
|
||||||
|
fill: $orange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
fill: $orange;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,29 @@
|
||||||
import React, { FC, HTMLAttributes } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { range } from 'ramda';
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {}
|
interface IProps {
|
||||||
|
title: string;
|
||||||
|
items: Partial<INode>[];
|
||||||
|
}
|
||||||
|
|
||||||
const NodeRelated: FC<IProps> = ({
|
const NodeRelated: FC<IProps> = ({ title, items }) => {
|
||||||
title,
|
return (
|
||||||
}) => (
|
<Group className={styles.wrap}>
|
||||||
<Group className={styles.wrap}>
|
<div className={styles.title}>
|
||||||
<div className={styles.title}>
|
<div className={styles.line} />
|
||||||
<div className={styles.line} />
|
<div className={styles.text}>{title}</div>
|
||||||
<div className={styles.text}>{title}</div>
|
<div className={styles.line} />
|
||||||
<div className={styles.line} />
|
</div>
|
||||||
</div>
|
<div className={styles.grid}>
|
||||||
<div className={styles.grid}>
|
{items.map(item => (
|
||||||
{
|
<NodeRelatedItem item={item} key={item.id} />
|
||||||
range(1, 7).map(el => (<div className={styles.item} key={el} />))
|
))}
|
||||||
}
|
</div>
|
||||||
</div>
|
</Group>
|
||||||
</Group>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export { NodeRelated };
|
export { NodeRelated };
|
||||||
|
|
|
@ -7,19 +7,16 @@
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
grid-template-columns: repeat(3, 1fr);
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
grid-auto-rows: auto;
|
grid-auto-rows: auto;
|
||||||
grid-column-gap: $gap;
|
grid-column-gap: $gap;
|
||||||
grid-row-gap: $gap;
|
grid-row-gap: $gap;
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
@include tablet {
|
||||||
background: darken($content_bg, 2%);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
padding-bottom: 100%;
|
}
|
||||||
border-radius: $cell_radius;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font: $font_14_semibold;
|
font: $font_14_semibold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
35
src/components/node/NodeRelatedItem/index.tsx
Normal file
35
src/components/node/NodeRelatedItem/index.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import React, { FC, memo, useCallback, useState } from 'react';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { URLS } from '~/constants/urls';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
|
||||||
|
type IProps = RouteComponentProps & {
|
||||||
|
item: Partial<INode>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeRelatedItemUnconnected: FC<IProps> = memo(({ item, history }) => {
|
||||||
|
const [is_loaded, setIsLoaded] = useState(false);
|
||||||
|
const onClick = useCallback(() => history.push(URLS.NODE_URL(item.id)), [item, history]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.item, { [styles.is_loaded]: is_loaded })}
|
||||||
|
key={item.id}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.thumb}
|
||||||
|
style={{ backgroundImage: `url("${getURL({ url: item.thumbnail })}")` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<img src={getURL({ url: item.thumbnail })} alt="loader" onLoad={() => setIsLoaded(true)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const NodeRelatedItem = withRouter(NodeRelatedItemUnconnected);
|
||||||
|
|
||||||
|
export { NodeRelatedItem };
|
30
src/components/node/NodeRelatedItem/styles.scss
Normal file
30
src/components/node/NodeRelatedItem/styles.scss
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
.item {
|
||||||
|
background: lighten($content_bg, 2%) 50% 50% no-repeat;
|
||||||
|
padding-bottom: 100%;
|
||||||
|
border-radius: $cell_radius;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: $cell_radius;
|
||||||
|
background: lighten($content_bg, 2%) 50% 50% no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
will-change: opacity;
|
||||||
|
|
||||||
|
.is_loaded & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, memo } from 'react';
|
||||||
import { Tags } from '../Tags';
|
import { Tags } from '../Tags';
|
||||||
import { ITag } from '~/redux/types';
|
import { ITag } from '~/redux/types';
|
||||||
|
|
||||||
|
@ -8,8 +8,8 @@ interface IProps {
|
||||||
onChange?: (tags: string[]) => void;
|
onChange?: (tags: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeTags: FC<IProps> = ({ is_editable, tags, onChange }) => (
|
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange }) => (
|
||||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
||||||
);
|
));
|
||||||
|
|
||||||
export { NodeTags };
|
export { NodeTags };
|
||||||
|
|
20
src/components/node/NodeTextBlock/index.tsx
Normal file
20
src/components/node/NodeTextBlock/index.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
import { formatText } from '~/utils/dom';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
node: INode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeTextBlock: FC<IProps> = ({ node }) => (
|
||||||
|
<div
|
||||||
|
className={styles.text}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: formatText(path(['blocks', 0, 'text'], node)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { NodeTextBlock };
|
13
src/components/node/NodeTextBlock/styles.scss
Normal file
13
src/components/node/NodeTextBlock/styles.scss
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
.text {
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
|
background: $content_bg;
|
||||||
|
padding: $gap * 4;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: $gap 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
36
src/components/node/NodeVideoBlock/index.tsx
Normal file
36
src/components/node/NodeVideoBlock/index.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import path from 'ramda/es/path';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
node: INode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeVideoBlock: FC<IProps> = ({ node }) => {
|
||||||
|
const video = useMemo(() => {
|
||||||
|
const url: string = path(['blocks', 0, 'url'], node);
|
||||||
|
const match =
|
||||||
|
url &&
|
||||||
|
url.match(
|
||||||
|
/http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?/
|
||||||
|
);
|
||||||
|
|
||||||
|
return match && match[1];
|
||||||
|
}, [node]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<iframe
|
||||||
|
width="560"
|
||||||
|
height="315"
|
||||||
|
src={`https://www.youtube.com/embed/${video}`}
|
||||||
|
frameBorder="0"
|
||||||
|
allowFullScreen
|
||||||
|
title="video"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NodeVideoBlock };
|
12
src/components/node/NodeVideoBlock/styles.scss
Normal file
12
src/components/node/NodeVideoBlock/styles.scss
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
.wrap {
|
||||||
|
padding-bottom: 56.25%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,6 @@
|
||||||
.tag {
|
.tag {
|
||||||
|
@include outer_shadow();
|
||||||
|
|
||||||
height: $tag_height;
|
height: $tag_height;
|
||||||
background: $tag_bg;
|
background: $tag_bg;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -9,8 +11,8 @@
|
||||||
font: $font_14_semibold;
|
font: $font_14_semibold;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0 8px 0 0;
|
padding: 0 8px 0 0;
|
||||||
box-shadow: $shadow_depth_2;
|
// box-shadow: $shadow_depth_2;
|
||||||
margin: ($gap / 2) $gap ($gap / 2) 0;
|
margin: 0 $gap $gap 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:global(.is_hoverable) {
|
&:global(.is_hoverable) {
|
||||||
|
@ -64,7 +66,7 @@
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-left: 23px;
|
padding-left: $tag_height;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
@ -74,10 +76,10 @@
|
||||||
width: $tag_height;
|
width: $tag_height;
|
||||||
height: $tag_height;
|
height: $tag_height;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-right: 3px;
|
// padding-right: 0px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 22px;
|
flex: 0 0 $tag_height;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { TagField } from '~/components/containers/TagField';
|
||||||
import { ITag } from '~/redux/types';
|
import { ITag } from '~/redux/types';
|
||||||
import { Tag } from '~/components/node/Tag';
|
import { Tag } from '~/components/node/Tag';
|
||||||
import uniq from 'ramda/es/uniq';
|
import uniq from 'ramda/es/uniq';
|
||||||
|
import assocPath from 'ramda/es/assocPath';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
tags: Partial<ITag>[];
|
tags: Partial<ITag>[];
|
||||||
|
@ -65,9 +66,14 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, ...props })
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = useCallback(() => {
|
const onSubmit = useCallback(() => {
|
||||||
if (!data.length) return;
|
const title = input && input.trim();
|
||||||
onTagsChange(uniq([...tags, ...data]).map(tag => tag.title));
|
const items = title ? [...data, { title }] : data;
|
||||||
}, [tags, data, onTagsChange]);
|
|
||||||
|
if (!items.length) return;
|
||||||
|
setData(items);
|
||||||
|
setInput('');
|
||||||
|
onTagsChange(uniq([...tags, ...items]).map(tag => tag.title));
|
||||||
|
}, [tags, data, onTagsChange, input, setInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));
|
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));
|
||||||
|
|
43
src/components/upload/AudioUpload/index.tsx
Normal file
43
src/components/upload/AudioUpload/index.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
import { ArcProgress } from '~/components/input/ArcProgress';
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
id?: IFile['id'];
|
||||||
|
title?: string;
|
||||||
|
progress?: number;
|
||||||
|
onDrop?: (file_id: IFile['id']) => void;
|
||||||
|
|
||||||
|
is_uploading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioUpload: FC<IProps> = ({ title, progress, is_uploading, id, onDrop }) => {
|
||||||
|
const onDropFile = useCallback(() => {
|
||||||
|
if (!id || !onDrop) return;
|
||||||
|
onDrop(id);
|
||||||
|
}, [id, onDrop]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{id && onDrop && (
|
||||||
|
<div className={styles.drop} onMouseDown={onDropFile}>
|
||||||
|
<Icon icon="close" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
|
||||||
|
{is_uploading && (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<ArcProgress size={40} progress={progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{title && <div className={styles.title}>{title}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AudioUpload };
|
75
src/components/upload/AudioUpload/styles.scss
Normal file
75
src/components/upload/AudioUpload/styles.scss
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
.wrap {
|
||||||
|
background: lighten($content_bg, 4%);
|
||||||
|
// padding-bottom: 100%;
|
||||||
|
border-radius: $radius;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
height: $comment_height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb_wrap {
|
||||||
|
// position: absolute;
|
||||||
|
// width: 100%;
|
||||||
|
// height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: $radius;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
flex: 0 0 $comment_height;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: none;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #222222;
|
||||||
|
position: absolute;
|
||||||
|
right: $gap;
|
||||||
|
top: $gap;
|
||||||
|
border-radius: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
transition: background-color 250ms, opacity 0.25s;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: $red;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,27 +1,43 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { ArcProgress } from '~/components/input/ArcProgress';
|
import { ArcProgress } from '~/components/input/ArcProgress';
|
||||||
|
import { IFile } from '~/redux/types';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
id?: string;
|
id?: IFile['id'];
|
||||||
thumb?: string;
|
thumb?: string;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
onDrop?: (file_id: IFile['id']) => void;
|
||||||
|
|
||||||
is_uploading?: boolean;
|
is_uploading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading }) => (
|
const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading, id, onDrop }) => {
|
||||||
<div className={styles.wrap}>
|
const onDropFile = useCallback(() => {
|
||||||
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
|
if (!id || !onDrop) return;
|
||||||
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
|
onDrop(id);
|
||||||
{is_uploading && (
|
}, [id, onDrop]);
|
||||||
<div className={styles.progress}>
|
|
||||||
<ArcProgress size={72} progress={progress} />
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{id && onDrop && (
|
||||||
|
<div className={styles.drop} onMouseDown={onDropFile}>
|
||||||
|
<Icon icon="close" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
|
||||||
|
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
|
||||||
|
{is_uploading && (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<ArcProgress size={72} progress={progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export { ImageUpload };
|
export { ImageUpload };
|
||||||
|
|
|
@ -57,3 +57,29 @@
|
||||||
.helper {
|
.helper {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #222222;
|
||||||
|
position: absolute;
|
||||||
|
right: $gap;
|
||||||
|
top: $gap;
|
||||||
|
border-radius: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
transition: background-color 250ms, opacity 0.25s;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,6 +13,10 @@ export const API = {
|
||||||
GET_NODE: (id: number | string) => `/node/${id}`,
|
GET_NODE: (id: number | string) => `/node/${id}`,
|
||||||
|
|
||||||
COMMENT: (id: INode['id']) => `/node/${id}/comment`,
|
COMMENT: (id: INode['id']) => `/node/${id}/comment`,
|
||||||
|
RELATED: (id: INode['id']) => `/node/${id}/related`,
|
||||||
UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`,
|
UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`,
|
||||||
|
POST_LIKE: (id: INode['id']) => `/node/${id}/like`,
|
||||||
|
POST_STAR: (id: INode['id']) => `/node/${id}/heroic`,
|
||||||
|
SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,8 +3,25 @@ export const ERRORS = {
|
||||||
TOO_SHIRT: 'Is_Too_Shirt',
|
TOO_SHIRT: 'Is_Too_Shirt',
|
||||||
EMPTY_RESPONSE: 'Empty_Response',
|
EMPTY_RESPONSE: 'Empty_Response',
|
||||||
NO_COMMENTS: 'No_Comments',
|
NO_COMMENTS: 'No_Comments',
|
||||||
|
FILES_REQUIRED: 'Files_Required',
|
||||||
|
TEXT_REQUIRED: 'Text_Required',
|
||||||
|
UNKNOWN_NODE_TYPE: 'Unknown_Node_Type',
|
||||||
|
URL_INVALID: 'Url_Invalid',
|
||||||
|
FILES_AUDIO_REQUIRED: 'Files_Audio_Required',
|
||||||
|
NOT_ENOUGH_RIGHTS: 'Not_Enough_Rights',
|
||||||
|
INCORRECT_DATA: 'Incorrect_Data',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ERROR_LITERAL = {
|
export const ERROR_LITERAL = {
|
||||||
|
[ERRORS.NOT_AN_EMAIL]: 'Введите правильный e-mail',
|
||||||
|
[ERRORS.TOO_SHIRT]: 'Слишком короткий',
|
||||||
[ERRORS.NO_COMMENTS]: 'Комментариев пока нет',
|
[ERRORS.NO_COMMENTS]: 'Комментариев пока нет',
|
||||||
|
[ERRORS.EMPTY_RESPONSE]: 'Пустой ответ сервера',
|
||||||
|
[ERRORS.FILES_REQUIRED]: 'Добавьте файлы',
|
||||||
|
[ERRORS.TEXT_REQUIRED]: 'Нужно немного текста',
|
||||||
|
[ERRORS.UNKNOWN_NODE_TYPE]: 'Неизвестный тип поста',
|
||||||
|
[ERRORS.URL_INVALID]: 'Неизвестный адрес',
|
||||||
|
[ERRORS.FILES_AUDIO_REQUIRED]: 'Нужна хотя бы одна песня',
|
||||||
|
[ERRORS.NOT_ENOUGH_RIGHTS]: 'У вас недостаточно прав',
|
||||||
|
[ERRORS.INCORRECT_DATA]: 'Недопустимые данные',
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,18 +14,22 @@ import { URLS } from '~/constants/urls';
|
||||||
import { Modal } from '~/containers/dialogs/Modal';
|
import { Modal } from '~/containers/dialogs/Modal';
|
||||||
import { selectModal } from '~/redux/modal/selectors';
|
import { selectModal } from '~/redux/modal/selectors';
|
||||||
import { BlurWrapper } from '~/components/containers/BlurWrapper';
|
import { BlurWrapper } from '~/components/containers/BlurWrapper';
|
||||||
|
import { PageCover } from '~/components/containers/PageCover';
|
||||||
import { NodeLayout } from './node/NodeLayout';
|
import { NodeLayout } from './node/NodeLayout';
|
||||||
import { BottomContainer } from '~/containers/main/BottomContainer';
|
import { BottomContainer } from '~/containers/main/BottomContainer';
|
||||||
|
|
||||||
const mapStateToProps = selectModal;
|
const mapStateToProps = state => ({
|
||||||
|
modal: selectModal(state),
|
||||||
|
});
|
||||||
const mapDispatchToProps = {};
|
const mapDispatchToProps = {};
|
||||||
|
|
||||||
type IProps = typeof mapDispatchToProps & ReturnType<typeof mapStateToProps> & {};
|
type IProps = typeof mapDispatchToProps & ReturnType<typeof mapStateToProps> & {};
|
||||||
|
|
||||||
const Component: FC<IProps> = ({ is_shown }) => (
|
const Component: FC<IProps> = ({ modal: { is_shown } }) => (
|
||||||
<ConnectedRouter history={history}>
|
<ConnectedRouter history={history}>
|
||||||
<div>
|
<div>
|
||||||
<BlurWrapper is_blurred={is_shown}>
|
<BlurWrapper is_blurred={is_shown}>
|
||||||
|
<PageCover />
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Modal />
|
<Modal />
|
||||||
<Sprites />
|
<Sprites />
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import React, { FC, useState, useCallback, useEffect, FormEvent } from 'react';
|
import React, { FC, useState, useCallback, FormEvent, useEffect, createElement } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import assocPath from 'ramda/es/assocPath';
|
|
||||||
import append from 'ramda/es/append';
|
|
||||||
import uuid from 'uuid4';
|
|
||||||
import { ScrollDialog } from '../ScrollDialog';
|
import { ScrollDialog } from '../ScrollDialog';
|
||||||
import { IDialogProps } from '~/redux/modal/constants';
|
import { IDialogProps } from '~/redux/modal/constants';
|
||||||
import { useCloseOnEscape } from '~/utils/hooks';
|
import { useCloseOnEscape } from '~/utils/hooks';
|
||||||
|
@ -12,117 +9,42 @@ import { Button } from '~/components/input/Button';
|
||||||
import { Padder } from '~/components/containers/Padder';
|
import { Padder } from '~/components/containers/Padder';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import { selectNode } from '~/redux/node/selectors';
|
import { selectNode } from '~/redux/node/selectors';
|
||||||
import { ImageEditor } from '~/components/editors/ImageEditor';
|
|
||||||
import { EditorPanel } from '~/components/editors/EditorPanel';
|
import { EditorPanel } from '~/components/editors/EditorPanel';
|
||||||
import { moveArrItem } from '~/utils/fn';
|
|
||||||
import { IFile, IFileWithUUID } from '~/redux/types';
|
|
||||||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
|
||||||
import * as NODE_ACTIONS from '~/redux/node/actions';
|
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||||
import { selectUploads } from '~/redux/uploads/selectors';
|
import { selectUploads } from '~/redux/uploads/selectors';
|
||||||
import { UPLOAD_TARGETS, UPLOAD_TYPES, UPLOAD_SUBJECTS } from '~/redux/uploads/constants';
|
import { ERROR_LITERAL } from '~/constants/errors';
|
||||||
|
import { NODE_EDITORS, EMPTY_NODE } from '~/redux/node/constants';
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const { editor } = selectNode(state);
|
const { editor, errors } = selectNode(state);
|
||||||
const { statuses, files } = selectUploads(state);
|
const { statuses, files } = selectUploads(state);
|
||||||
|
|
||||||
return { editor, statuses, files };
|
return { editor, statuses, files, errors };
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
|
|
||||||
nodeSave: NODE_ACTIONS.nodeSave,
|
nodeSave: NODE_ACTIONS.nodeSave,
|
||||||
|
nodeSetSaveErrors: NODE_ACTIONS.nodeSetSaveErrors,
|
||||||
};
|
};
|
||||||
|
|
||||||
type IProps = IDialogProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
type IProps = IDialogProps &
|
||||||
|
ReturnType<typeof mapStateToProps> &
|
||||||
|
typeof mapDispatchToProps & {
|
||||||
|
type: typeof NODE_EDITORS[keyof typeof NODE_EDITORS];
|
||||||
|
};
|
||||||
|
|
||||||
const EditorDialogUnconnected: FC<IProps> = ({
|
const EditorDialogUnconnected: FC<IProps> = ({
|
||||||
onRequestClose,
|
|
||||||
editor,
|
editor,
|
||||||
files,
|
errors,
|
||||||
statuses,
|
|
||||||
|
|
||||||
uploadUploadFiles,
|
|
||||||
nodeSave,
|
nodeSave,
|
||||||
|
nodeSetSaveErrors,
|
||||||
|
onRequestClose,
|
||||||
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const [data, setData] = useState(editor);
|
const [data, setData] = useState(EMPTY_NODE);
|
||||||
const eventPreventer = useCallback(event => event.preventDefault(), []);
|
|
||||||
const [temp, setTemp] = useState([]);
|
const [temp, setTemp] = useState([]);
|
||||||
|
|
||||||
const onUpload = useCallback(
|
useEffect(() => setData(editor), [editor]);
|
||||||
(uploads: File[]) => {
|
|
||||||
const items: IFileWithUUID[] = Array.from(uploads).map(
|
|
||||||
(file: File): IFileWithUUID => ({
|
|
||||||
file,
|
|
||||||
temp_id: uuid(),
|
|
||||||
subject: UPLOAD_SUBJECTS.EDITOR,
|
|
||||||
target: UPLOAD_TARGETS.NODES,
|
|
||||||
type: UPLOAD_TYPES.IMAGE,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const temps = items.map(file => file.temp_id);
|
|
||||||
|
|
||||||
setTemp([...temp, ...temps]);
|
|
||||||
uploadUploadFiles(items);
|
|
||||||
},
|
|
||||||
[setTemp, uploadUploadFiles, temp]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFileMove = useCallback(
|
|
||||||
(old_index: number, new_index: number) => {
|
|
||||||
setData(assocPath(['files'], moveArrItem(old_index, new_index, data.files), data));
|
|
||||||
},
|
|
||||||
[data, setData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFileAdd = useCallback(
|
|
||||||
(file: IFile) => {
|
|
||||||
setData(assocPath(['files'], append(file, data.files), data));
|
|
||||||
},
|
|
||||||
[data, setData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDrop = useCallback(
|
|
||||||
(event: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!event.dataTransfer || !event.dataTransfer.files || !event.dataTransfer.files.length)
|
|
||||||
return;
|
|
||||||
|
|
||||||
onUpload(Array.from(event.dataTransfer.files));
|
|
||||||
},
|
|
||||||
[onUpload]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onInputChange = useCallback(
|
|
||||||
event => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!event.target.files || !event.target.files.length) return;
|
|
||||||
|
|
||||||
onUpload(Array.from(event.target.files));
|
|
||||||
},
|
|
||||||
[onUpload]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('dragover', eventPreventer, false);
|
|
||||||
window.addEventListener('drop', eventPreventer, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('dragover', eventPreventer, false);
|
|
||||||
window.removeEventListener('drop', eventPreventer, false);
|
|
||||||
};
|
|
||||||
}, [eventPreventer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Object.entries(statuses).forEach(([id, status]) => {
|
|
||||||
if (temp.includes(id) && !!status.uuid && files[status.uuid]) {
|
|
||||||
onFileAdd(files[status.uuid]);
|
|
||||||
setTemp(temp.filter(el => el !== id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [statuses, files, temp, onFileAdd]);
|
|
||||||
|
|
||||||
const setTitle = useCallback(
|
const setTitle = useCallback(
|
||||||
title => {
|
title => {
|
||||||
|
@ -139,9 +61,18 @@ const EditorDialogUnconnected: FC<IProps> = ({
|
||||||
[data, nodeSave]
|
[data, nodeSave]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!NODE_EDITORS[type] && onRequestClose) onRequestClose();
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Object.keys(errors).length) return;
|
||||||
|
nodeSetSaveErrors({});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const buttons = (
|
const buttons = (
|
||||||
<Padder style={{ position: 'relative' }}>
|
<Padder style={{ position: 'relative' }}>
|
||||||
<EditorPanel data={data} setData={setData} onUpload={onInputChange} />
|
<EditorPanel data={data} setData={setData} temp={temp} setTemp={setTemp} />
|
||||||
|
|
||||||
<Group horizontal>
|
<Group horizontal>
|
||||||
<InputText title="Название" value={data.title} handler={setTitle} autoFocus />
|
<InputText title="Название" value={data.title} handler={setTitle} autoFocus />
|
||||||
|
@ -153,18 +84,25 @@ const EditorDialogUnconnected: FC<IProps> = ({
|
||||||
|
|
||||||
useCloseOnEscape(onRequestClose);
|
useCloseOnEscape(onRequestClose);
|
||||||
|
|
||||||
|
const error = errors && Object.values(errors)[0];
|
||||||
|
|
||||||
|
if (!NODE_EDITORS[type]) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className={styles.form}>
|
<form onSubmit={onSubmit} className={styles.form}>
|
||||||
<ScrollDialog buttons={buttons} width={860} onClose={onRequestClose}>
|
<ScrollDialog
|
||||||
<div className={styles.editor} onDrop={onDrop}>
|
buttons={buttons}
|
||||||
<ImageEditor
|
width={860}
|
||||||
data={data}
|
error={error && ERROR_LITERAL[error]}
|
||||||
pending_files={temp.filter(id => !!statuses[id]).map(id => statuses[id])}
|
onClose={onRequestClose}
|
||||||
setData={setData}
|
>
|
||||||
onUpload={onInputChange}
|
<div className={styles.editor}>
|
||||||
onFileMove={onFileMove}
|
{createElement(NODE_EDITORS[type], {
|
||||||
onInputChange={onInputChange}
|
data,
|
||||||
/>
|
setData,
|
||||||
|
temp,
|
||||||
|
setTemp,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollDialog>
|
</ScrollDialog>
|
||||||
</form>
|
</form>
|
||||||
|
|
11
src/containers/dialogs/LoadingDialog/index.tsx
Normal file
11
src/containers/dialogs/LoadingDialog/index.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { LoaderCircle } from '~/components/input/LoaderCircle';
|
||||||
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
|
const LoadingDialog: FC<{}> = () => (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<LoaderCircle size={64} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { LoadingDialog };
|
11
src/containers/dialogs/LoadingDialog/styles.scss
Normal file
11
src/containers/dialogs/LoadingDialog/styles.scss
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.wrap {
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: white;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,18 +38,18 @@ const ModalUnconnected: FC<IProps> = ({
|
||||||
{React.createElement(DIALOG_CONTENT[dialog], {
|
{React.createElement(DIALOG_CONTENT[dialog], {
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
onDialogChange: modalShowDialog,
|
onDialogChange: modalShowDialog,
|
||||||
} as IDialogProps)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Modal = connect(
|
const Modal = connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
mapDispatchToProps,
|
mapDispatchToProps
|
||||||
)(ModalUnconnected);
|
)(ModalUnconnected);
|
||||||
|
|
||||||
export { ModalUnconnected, Modal };
|
export { ModalUnconnected, Modal };
|
||||||
|
|
|
@ -133,7 +133,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
background: linear-gradient(transparentize($orange, 1), $red);
|
background: linear-gradient(transparentize($orange, 1), $red 90%);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
10
src/containers/editors/EditorDialogAudio/index.tsx
Normal file
10
src/containers/editors/EditorDialogAudio/index.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { EditorDialog } from '~/containers/dialogs/EditorDialog';
|
||||||
|
import { IDialogProps } from '~/redux/types';
|
||||||
|
import { NODE_TYPES } from '~/redux/node/constants';
|
||||||
|
|
||||||
|
type IProps = IDialogProps & {};
|
||||||
|
|
||||||
|
const EditorDialogAudio: FC<IProps> = props => <EditorDialog type={NODE_TYPES.AUDIO} {...props} />;
|
||||||
|
|
||||||
|
export { EditorDialogAudio };
|
10
src/containers/editors/EditorDialogImage/index.tsx
Normal file
10
src/containers/editors/EditorDialogImage/index.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { EditorDialog } from '~/containers/dialogs/EditorDialog';
|
||||||
|
import { IDialogProps } from '~/redux/types';
|
||||||
|
import { NODE_TYPES } from '~/redux/node/constants';
|
||||||
|
|
||||||
|
type IProps = IDialogProps & {};
|
||||||
|
|
||||||
|
const EditorDialogImage: FC<IProps> = props => <EditorDialog type={NODE_TYPES.IMAGE} {...props} />;
|
||||||
|
|
||||||
|
export { EditorDialogImage };
|
10
src/containers/editors/EditorDialogText/index.tsx
Normal file
10
src/containers/editors/EditorDialogText/index.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { EditorDialog } from '~/containers/dialogs/EditorDialog';
|
||||||
|
import { IDialogProps } from '~/redux/types';
|
||||||
|
import { NODE_TYPES } from '~/redux/node/constants';
|
||||||
|
|
||||||
|
type IProps = IDialogProps & {};
|
||||||
|
|
||||||
|
const EditorDialogText: FC<IProps> = props => <EditorDialog type={NODE_TYPES.TEXT} {...props} />;
|
||||||
|
|
||||||
|
export { EditorDialogText };
|
10
src/containers/editors/EditorDialogVideo/index.tsx
Normal file
10
src/containers/editors/EditorDialogVideo/index.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { EditorDialog } from '~/containers/dialogs/EditorDialog';
|
||||||
|
import { IDialogProps } from '~/redux/types';
|
||||||
|
import { NODE_TYPES } from '~/redux/node/constants';
|
||||||
|
|
||||||
|
type IProps = IDialogProps & {};
|
||||||
|
|
||||||
|
const EditorDialogVideo: FC<IProps> = props => <EditorDialog type={NODE_TYPES.VIDEO} {...props} />;
|
||||||
|
|
||||||
|
export { EditorDialogVideo };
|
0
src/containers/editors/EditorDialogVideo/styles.scss
Normal file
0
src/containers/editors/EditorDialogVideo/styles.scss
Normal file
|
@ -3,15 +3,35 @@ 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 pick from 'ramda/es/pick';
|
||||||
|
import { selectUser } from '~/redux/auth/selectors';
|
||||||
|
|
||||||
const mapStateToProps = selectFlow;
|
const mapStateToProps = state => ({
|
||||||
|
flow: pick(['nodes', 'heroes'], selectFlow(state)),
|
||||||
|
user: pick(['role', 'id'], selectUser(state)),
|
||||||
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = { nodeLoadNode: NODE_ACTIONS.nodeLoadNode };
|
const mapDispatchToProps = {
|
||||||
|
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
|
||||||
|
flowSetCellView: FLOW_ACTIONS.flowSetCellView,
|
||||||
|
};
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
const FlowLayoutUnconnected: FC<IProps> = ({ nodes, nodeLoadNode }) => (
|
const FlowLayoutUnconnected: FC<IProps> = ({
|
||||||
<FlowGrid nodes={nodes} onSelect={nodeLoadNode} />
|
flow: { nodes, heroes },
|
||||||
|
user,
|
||||||
|
nodeLoadNode,
|
||||||
|
flowSetCellView,
|
||||||
|
}) => (
|
||||||
|
<FlowGrid
|
||||||
|
nodes={nodes}
|
||||||
|
heroes={heroes}
|
||||||
|
onSelect={nodeLoadNode}
|
||||||
|
user={user}
|
||||||
|
onChangeCellView={flowSetCellView}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const FlowLayout = connect(
|
const FlowLayout = connect(
|
||||||
|
|
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