mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-07-07 16:58:29 +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
.vscode
package-lock.jsonpackage.jsonsrc
components
bars
containers
BlurWrapper
CommentWrapper
Filler
PageCover
Scroll
editors
AudioEditor
AudioGrid
EditorAudioUploadButton
EditorImageUploadButton
EditorPanel
EditorUploadButton
EditorUploadCoverButton
ImageEditor
ImageGrid
SortableAudioGrid
SortableAudioGridItem
SortableImageGrid
SortableImageGridItem
TextEditor
VideoEditor
flow
Cell
FlowGrid
FlowHero
HeroPlaceholder
input
main/Header
media/AudioPlayer
node
Comment
CommentContent
CommentForm
NodeAudioBlock
NodeAudioImageBlock
NodeComments
NodeImageSlideBlock
NodePanel
NodePanelInner
NodeRelated
NodeRelatedItem
NodeTags
NodeTextBlock
NodeVideoBlock
Tag
Tags
upload
constants
containers
App.tsx
dialogs
editors
EditorDialogAudio
EditorDialogImage
EditorDialogText
EditorDialogVideo
flow/FlowLayout
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -17,4 +17,5 @@
|
|||
"editor.formatOnSave": true,
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.6.3",
|
||||
"@babel/cli": "^7.6.4",
|
||||
"@babel/preset-env": "^7.6.3",
|
||||
"@babel/types": "7.5.5",
|
||||
"@types/react-router": "^5.0.3",
|
||||
"@types/react-router": "^5.1.2",
|
||||
"autoresponsive-react": "^1.1.31",
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"babel-core": "^6.26.3",
|
||||
|
@ -42,7 +42,7 @@
|
|||
"ts-node": "^8.4.1",
|
||||
"typescript": "^3.6.4",
|
||||
"uglifyjs-webpack-plugin": "^1.3.0",
|
||||
"webpack": "^4.41.0",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.3.9",
|
||||
"webpack-dev-server": "^3.8.2"
|
||||
},
|
||||
|
@ -51,7 +51,7 @@
|
|||
"@hot-loader/react-dom": "^16.10.2",
|
||||
"@types/classnames": "^2.2.7",
|
||||
"@types/node": "^11.13.22",
|
||||
"@types/ramda": "^0.26.29",
|
||||
"@types/ramda": "^0.26.33",
|
||||
"@types/react": "16.8.23",
|
||||
"@typescript-eslint/eslint-plugin": "^1.13.0",
|
||||
"@typescript-eslint/parser": "^1.13.0",
|
||||
|
@ -61,7 +61,7 @@
|
|||
"clean-webpack-plugin": "^0.1.9",
|
||||
"connected-react-router": "^6.3.2",
|
||||
"date-fns": "^2.4.1",
|
||||
"dotenv": "^8.1.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"dotenv-webpack": "^1.7.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-config-airbnb": "^17.1.1",
|
||||
|
@ -104,6 +104,7 @@
|
|||
"sass-loader": "^7.3.1",
|
||||
"sass-resources-loader": "^2.0.0",
|
||||
"scrypt": "^6.0.3",
|
||||
"sticky-sidebar": "^3.3.1",
|
||||
"throttle-debounce": "^2.1.0",
|
||||
"tslint": "^5.20.0",
|
||||
"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 { Icon } from '~/components/input/Icon';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
|
@ -7,29 +7,85 @@ import { connect } from 'react-redux';
|
|||
import pick from 'ramda/es/pick';
|
||||
import { selectPlayer } from '~/redux/player/selectors';
|
||||
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 = {
|
||||
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||
playerPause: PLAYER_ACTIONS.playerPause,
|
||||
playerSeek: PLAYER_ACTIONS.playerSeek,
|
||||
playerStop: PLAYER_ACTIONS.playerStop,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const metadata: IFile['metadata'] = path(['metadata'], file);
|
||||
const title =
|
||||
metadata &&
|
||||
(metadata.title || [metadata.id3artist, metadata.id3title].filter(el => !!el).join(' - '));
|
||||
|
||||
return (
|
||||
<div className={styles.place}>
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.status}>
|
||||
<div className={styles.playpause}>
|
||||
<Icon icon="play" />
|
||||
<div className={styles.playpause} onClick={onClick}>
|
||||
{status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,46 +1,43 @@
|
|||
.place {
|
||||
position: relative;
|
||||
height: 54px;
|
||||
height: $bar_height;
|
||||
flex: 0 1 500px;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
.seeker {
|
||||
transform: translate(0, -64px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrap {
|
||||
@include outer_shadow();
|
||||
display: flex;
|
||||
border-radius: 27px;
|
||||
background: $green_gradient;
|
||||
border-radius: $radius $radius 0 0;
|
||||
// background: $main_gradient;
|
||||
align-items: center;
|
||||
box-shadow: rgba(0, 0, 0, 0.5) 0 2px 5px, inset rgba(255, 255, 255, 0.3) 0 1px,
|
||||
inset rgba(0, 0, 0, 0.3) 0 -1px;
|
||||
background: lighten($content_bg, 6%);
|
||||
// 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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
height: $bar_height;
|
||||
flex-direction: column;
|
||||
transform: translate(0, 0);
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 0 0 54px;
|
||||
flex: 0 0 $bar_height;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
height: 54px;
|
||||
height: $bar_height;
|
||||
}
|
||||
|
||||
.playpause,
|
||||
.close {
|
||||
flex: 0 0 48px;
|
||||
flex: 0 0 $bar_height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
@ -49,7 +46,7 @@
|
|||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
fill: $content_bg;
|
||||
fill: darken(white, 50%);
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
@ -60,3 +57,51 @@
|
|||
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 {
|
||||
filter: blur(0);
|
||||
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 { Card } from '../Card';
|
||||
import { IUser } from '~/redux/auth/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import path from 'ramda/es/path';
|
||||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||
photo?: string;
|
||||
// photo?: string;
|
||||
user: IUser;
|
||||
is_empty?: boolean;
|
||||
is_loading?: boolean;
|
||||
is_same?: boolean;
|
||||
};
|
||||
|
||||
const CommentWrapper: FC<IProps> = ({
|
||||
photo,
|
||||
// photo,
|
||||
children,
|
||||
is_empty,
|
||||
is_loading,
|
||||
className,
|
||||
is_same,
|
||||
user,
|
||||
...props
|
||||
}) => (
|
||||
<Card
|
||||
|
@ -26,9 +31,11 @@ const CommentWrapper: FC<IProps> = ({
|
|||
{...props}
|
||||
>
|
||||
<div className={styles.thumb}>
|
||||
{!is_same && photo && (
|
||||
<div className={styles.thumb_image} style={{ backgroundImage: `url("${photo}")` }} />
|
||||
)}
|
||||
<div
|
||||
className={styles.thumb_image}
|
||||
style={{ backgroundImage: `url("${getURL(path(['photo'], user))}")` }}
|
||||
/>
|
||||
<div className={styles.thumb_user}>~{path(['username'], user)}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.text}>{children}</div>
|
||||
|
|
|
@ -14,17 +14,39 @@
|
|||
margin: 0 !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@include tablet {
|
||||
:global(.comment-author) {
|
||||
display: none !important;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex: 0 0 $comment_height;
|
||||
border-radius: $panel_radius 0 0 $panel_radius;
|
||||
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 {
|
||||
|
@ -32,4 +54,26 @@
|
|||
background: transparentize(white, 0.97) no-repeat 50% 50%;
|
||||
border-radius: $panel_radius 0 0 $panel_radius;
|
||||
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>;
|
||||
|
||||
export const Filler: FC<IProps> = ({
|
||||
className = '',
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.filler,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
export const Filler: FC<IProps> = ({ className = '', ...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;
|
||||
}
|
||||
|
||||
export const Scroll = ({
|
||||
children,
|
||||
className = '',
|
||||
onRef = null,
|
||||
...props
|
||||
}: IProps) => {
|
||||
const Scroll = ({ children, className = '', onRef = null, ...props }: IProps) => {
|
||||
const [ref, setRef] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -43,3 +38,5 @@ export const Scroll = ({
|
|||
</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 { INode } from '~/redux/types';
|
||||
import { EditorUploadButton } from '~/components/editors/EditorUploadButton';
|
||||
import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants';
|
||||
|
||||
interface IProps {
|
||||
data: INode;
|
||||
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}>
|
||||
<EditorUploadButton onUpload={onUpload} />
|
||||
{NODE_PANEL_COMPONENTS[data.type] &&
|
||||
NODE_PANEL_COMPONENTS[data.type].map((el, key) =>
|
||||
createElement(el, { key, data, setData, temp, setTemp })
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -7,4 +7,17 @@
|
|||
box-sizing: border-box;
|
||||
padding: $gap;
|
||||
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 { 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 {
|
||||
onUpload?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
const mapStateToProps = state => {
|
||||
const { statuses, files } = selectUploads(state);
|
||||
|
||||
const EditorUploadButton: FC<IProps> = ({
|
||||
onUpload,
|
||||
}) => (
|
||||
return { statuses, files };
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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={onUpload} accept="image/*" multiple />
|
||||
<input type="file" onChange={onInputChange} accept={accept} multiple />
|
||||
|
||||
<div className={styles.icon}>
|
||||
<Icon size={32} icon="plus" />
|
||||
<Icon size={32} icon={icon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const EditorUploadButton = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(EditorUploadButtonUnconnected);
|
||||
|
||||
export { EditorUploadButton };
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
.wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 32px !important;
|
||||
@include outer_shadow();
|
||||
|
||||
width: $upload_button_height;
|
||||
height: $upload_button_height;
|
||||
border-radius: ($upload_button_height / 2) !important;
|
||||
position: relative;
|
||||
border-radius: $radius;
|
||||
cursor: pointer;
|
||||
// opacity: 0.7;
|
||||
transition: opacity 0.5s;
|
||||
background: $red_gradient;
|
||||
box-shadow: $content_bg 0 0 5px 10px;
|
||||
// box-shadow: $content_bg 0 0 5px 10px;
|
||||
|
||||
&:hover {
|
||||
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 { INode } from '~/redux/types';
|
||||
import { INode, IFile } from '~/redux/types';
|
||||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||
import { selectUploads } from '~/redux/uploads/selectors';
|
||||
import { ImageGrid } from '~/components/editors/ImageGrid';
|
||||
import { IUploadStatus } from '~/redux/uploads/reducer';
|
||||
import * as styles from './styles.scss';
|
||||
|
||||
const mapStateToProps = selectUploads;
|
||||
const mapDispatchToProps = {
|
||||
|
@ -14,26 +14,25 @@ const mapDispatchToProps = {
|
|||
type IProps = ReturnType<typeof mapStateToProps> &
|
||||
typeof mapDispatchToProps & {
|
||||
data: INode;
|
||||
pending_files: IUploadStatus[];
|
||||
|
||||
setData: (val: INode) => void;
|
||||
onFileMove: (from: number, to: number) => void;
|
||||
onInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||
temp: string[];
|
||||
setTemp: (val: string[]) => void;
|
||||
};
|
||||
|
||||
const ImageEditorUnconnected: FC<IProps> = ({
|
||||
data,
|
||||
onFileMove,
|
||||
onInputChange,
|
||||
pending_files,
|
||||
}) => (
|
||||
<ImageGrid
|
||||
onFileMove={onFileMove}
|
||||
items={data.files}
|
||||
locked={pending_files}
|
||||
onUpload={onInputChange}
|
||||
/>
|
||||
);
|
||||
const ImageEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
|
||||
const pending_files = useMemo(() => temp.filter(id => !!statuses[id]).map(id => statuses[id]), [
|
||||
temp,
|
||||
statuses,
|
||||
]);
|
||||
|
||||
const setFiles = useCallback((files: IFile[]) => setData({ ...data, files }), [data, setData]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<ImageGrid files={data.files} setFiles={setFiles} locked={pending_files} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageEditor = connect(
|
||||
mapStateToProps,
|
||||
|
|
|
@ -1,14 +1,4 @@
|
|||
.uploads {
|
||||
.wrap {
|
||||
min-height: 200px;
|
||||
padding-bottom: 60px;
|
||||
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));
|
||||
}
|
||||
padding-bottom: $upload_button_height + $gap;
|
||||
}
|
||||
|
|
|
@ -1,59 +1,39 @@
|
|||
import React, { FC, useCallback, ChangeEventHandler, DragEventHandler } from 'react';
|
||||
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { SortEnd } from 'react-sortable-hoc';
|
||||
import * as styles from './styles.scss';
|
||||
import { ImageUpload } from '~/components/upload/ImageUpload';
|
||||
import { IFile } from '~/redux/types';
|
||||
import { IUploadStatus } from '~/redux/uploads/reducer';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { moveArrItem } from '~/utils/fn';
|
||||
import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
|
||||
|
||||
interface IProps {
|
||||
items: IFile[];
|
||||
files: IFile[];
|
||||
setFiles: (val: IFile[]) => void;
|
||||
locked: IUploadStatus[];
|
||||
onFileMove: (o: number, n: number) => void;
|
||||
onUpload?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const SortableItem = SortableElement(({ children }) => (
|
||||
<div className={styles.item}>{children}</div>
|
||||
));
|
||||
const ImageGrid: FC<IProps> = ({ files, setFiles, locked }) => {
|
||||
const onMove = useCallback(
|
||||
({ oldIndex, newIndex }: SortEnd) => {
|
||||
setFiles(moveArrItem(oldIndex, newIndex, files.filter(file => !!file)) as IFile[]);
|
||||
},
|
||||
[setFiles, files]
|
||||
);
|
||||
|
||||
const SortableList = SortableContainer(
|
||||
({
|
||||
items,
|
||||
locked,
|
||||
}: {
|
||||
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,
|
||||
]);
|
||||
const onDrop = useCallback(
|
||||
(remove_id: IFile['id']) => {
|
||||
setFiles(files.filter(file => file && file.id !== remove_id));
|
||||
},
|
||||
[setFiles, files]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableList
|
||||
<SortableImageGrid
|
||||
onDrop={onDrop}
|
||||
onSortEnd={onMove}
|
||||
axis="xy"
|
||||
items={items}
|
||||
items={files}
|
||||
locked={locked}
|
||||
onUpload={onUpload}
|
||||
pressDelay={window.innerWidth < 768 ? 200 : 0}
|
||||
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 {
|
||||
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 { URLS } from '~/constants/urls';
|
||||
import { getImageSize, getURL } from '~/utils/dom';
|
||||
import classNames = require('classnames');
|
||||
import { getURL } from '~/utils/dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import * as styles from './styles.scss';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { flowSetCellView } from '~/redux/flow/actions';
|
||||
|
||||
interface IProps {
|
||||
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;
|
||||
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 onImageLoad = useCallback(() => {
|
||||
setIsLoaded(true);
|
||||
}, [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 (
|
||||
<div
|
||||
className={classNames(styles.cell, 'vert-1', 'hor-1', { is_text: false })}
|
||||
onClick={onClick}
|
||||
className={classNames(styles.cell, styles[(flow && flow.display) || 'single'], {
|
||||
[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
|
||||
className={styles.thumbnail}
|
||||
style={{
|
||||
backgroundImage: `url("${getURL({ url: brief.thumbnail })}")`,
|
||||
backgroundImage: `url("${getURL({ url: thumbnail })}")`,
|
||||
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>
|
||||
|
|
|
@ -6,17 +6,16 @@
|
|||
background: $cell_bg;
|
||||
border-radius: $cell_radius;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
|
||||
&:global(.is_hero) {
|
||||
.is_hero {
|
||||
.title {
|
||||
font: $font_hero_title;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.is_text) {
|
||||
.is_text {
|
||||
.title {
|
||||
display: none;
|
||||
}
|
||||
|
@ -26,16 +25,10 @@
|
|||
}
|
||||
|
||||
.text {
|
||||
font: $font_16_regular;
|
||||
line-height: 1.3em;
|
||||
font: $font_18_regular;
|
||||
line-height: 22px;
|
||||
margin-top: $gap;
|
||||
letter-spacing: 0.5px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: $gap;
|
||||
background: darken($content_bg, 4%);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
|
@ -43,55 +36,63 @@
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
height: 160px;
|
||||
pointer-events: 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;
|
||||
border-radius: 0 0 $radius $radius;
|
||||
}
|
||||
|
||||
@media (max-width: $cell * 2 + $grid_line) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.text_title {
|
||||
font: $font_cell_title;
|
||||
line-height: 1.1em;
|
||||
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
|
||||
box-sizing: border-box;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-height: 2.6em;
|
||||
// max-height: 3.3em;
|
||||
}
|
||||
|
||||
.text_title {
|
||||
margin-bottom: $gap / 2;
|
||||
}
|
||||
|
||||
:global {
|
||||
.vert-1 {
|
||||
grid-row-end: span 1;
|
||||
}
|
||||
.horizontal,
|
||||
.quadro {
|
||||
grid-column-end: span 2;
|
||||
}
|
||||
|
||||
.vert-2 {
|
||||
.vertical,
|
||||
.quadro {
|
||||
grid-row-end: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.hor-1 {
|
||||
@media (max-width: $cell * 2) {
|
||||
.horizontal,
|
||||
.quadro,
|
||||
.vertical,
|
||||
.quadro {
|
||||
grid-row-end: span 1;
|
||||
grid-column-end: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hor-2 {
|
||||
grid-column-end: span 2;
|
||||
}
|
||||
|
||||
.is_text {
|
||||
.is_text {
|
||||
background: none;
|
||||
padding: 10px;
|
||||
box-shadow: inset #444 0 0 0 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
|
@ -106,6 +107,7 @@
|
|||
border-radius: $cell_radius + 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
will-change: transform;
|
||||
|
||||
& > img {
|
||||
opacity: 0;
|
||||
|
@ -115,6 +117,10 @@
|
|||
}
|
||||
|
||||
.face {
|
||||
@include outer_shadow();
|
||||
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -125,4 +131,154 @@
|
|||
z-index: 2;
|
||||
border-radius: $cell_radius;
|
||||
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 { IFlowState } from '~/redux/flow/reducer';
|
||||
import { INode } from '~/redux/types';
|
||||
import { canEditNode } from '~/utils/node';
|
||||
import { IUser } from '~/redux/auth/types';
|
||||
import { flowSetCellView } from '~/redux/flow/actions';
|
||||
import { FlowHero } from '../FlowHero';
|
||||
|
||||
type IProps = Partial<IFlowState> & {
|
||||
user: Partial<IUser>;
|
||||
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 className={styles.grid_test}>
|
||||
<div className={styles.hero}>HERO</div>
|
||||
<div className={styles.hero}>
|
||||
<FlowHero heroes={heroes} />
|
||||
</div>
|
||||
<div className={styles.stamp}>STAMP</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
// {
|
||||
// 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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax($cell, 1fr));
|
||||
grid-template-rows: $cell;
|
||||
grid-template-rows: 50vh $cell;
|
||||
grid-auto-rows: $cell;
|
||||
grid-auto-flow: row dense;
|
||||
grid-column-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 {
|
||||
|
|
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 {
|
||||
height: 280px;
|
||||
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;
|
||||
opacity: 0.7;
|
||||
will-change: transform;
|
||||
//box-shadow: white 0 0 0 1px;
|
||||
//border-radius: $panel_radius $panel_radius 0 0;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { Icon } from '~/components/input/Icon';
|
||||
import { IIcon } from '~/redux/types';
|
||||
|
@ -22,7 +22,8 @@ type IButtonProps = DetailedHTMLProps<
|
|||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export const Button: FC<IButtonProps> = ({
|
||||
const Button: FC<IButtonProps> = memo(
|
||||
({
|
||||
className = '',
|
||||
size = 'normal',
|
||||
iconLeft,
|
||||
|
@ -39,7 +40,7 @@ export const Button: FC<IButtonProps> = ({
|
|||
disabled,
|
||||
iconOnly,
|
||||
...props
|
||||
}) =>
|
||||
}) =>
|
||||
createElement(
|
||||
seamless || non_submitting ? 'div' : 'button',
|
||||
{
|
||||
|
@ -62,4 +63,7 @@ export const Button: FC<IButtonProps> = ({
|
|||
title ? <span>{title}</span> : children || null,
|
||||
iconRight && <Icon icon={iconRight} size={20} key={2} />,
|
||||
]
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
export { Button };
|
||||
|
|
|
@ -108,6 +108,7 @@
|
|||
&:global(.disabled),
|
||||
&:global(.grey) {
|
||||
background: transparentize(white, 0.9);
|
||||
color: white;
|
||||
// background: lighten(white, 0.5);
|
||||
// filter: grayscale(100%);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
import React, {
|
||||
FC,
|
||||
ChangeEvent,
|
||||
useCallback,
|
||||
useState, useEffect,
|
||||
} from 'react';
|
||||
import React, { FC, ChangeEvent, useCallback, useState, useEffect } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as styles from '~/styles/inputs.scss';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
|
@ -28,7 +23,7 @@ const InputText: FC<IInputTextProps> = ({
|
|||
|
||||
const onInput = useCallback(
|
||||
({ target }: ChangeEvent<HTMLInputElement>) => handler(target.value),
|
||||
[handler],
|
||||
[handler]
|
||||
);
|
||||
|
||||
const onFocus = useCallback(() => setFocused(true), []);
|
||||
|
@ -39,18 +34,15 @@ const InputText: FC<IInputTextProps> = ({
|
|||
}, [inner_ref, onRef]);
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.input_text_wrapper,
|
||||
wrapperClassName,
|
||||
{
|
||||
<div
|
||||
className={classNames(styles.input_text_wrapper, wrapperClassName, {
|
||||
[styles.required]: required,
|
||||
[styles.focused]: focused,
|
||||
[styles.has_status]: !!status || !!error,
|
||||
[styles.has_value]: !!value,
|
||||
[styles.has_error]: !!error,
|
||||
[styles.has_loader]: is_loading,
|
||||
},
|
||||
)}
|
||||
})}
|
||||
>
|
||||
<div className={styles.input}>
|
||||
<input
|
||||
|
@ -79,12 +71,16 @@ const InputText: FC<IInputTextProps> = ({
|
|||
<LoaderCircle size={20} />
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
title && <div className={styles.title}><span>{title}</span></div>
|
||||
}
|
||||
{
|
||||
error && <div className={styles.error}><span>{error}</span></div>
|
||||
}
|
||||
{title && (
|
||||
<div className={styles.title}>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
<span>{error}</span>
|
||||
</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 { push as historyPush } from 'connected-react-router';
|
||||
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 { pick } from 'ramda';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { url } from 'inspector';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import path from 'ramda/es/path';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
user: pick(['username', 'is_user', 'photo'])(selectUser(state)),
|
||||
|
@ -27,9 +25,8 @@ const 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 onOpenEditor = useCallback(() => showDialog(DIALOGS.EDITOR), [showDialog]);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
|
@ -38,7 +35,6 @@ const HeaderUnconnected: FC<IProps> = ({ user: { username, is_user, photo }, sho
|
|||
<Filler />
|
||||
|
||||
<div className={style.plugs}>
|
||||
<div onClick={onOpenEditor}>editor</div>
|
||||
<Link to="/">flow</Link>
|
||||
</div>
|
||||
|
||||
|
@ -58,7 +54,7 @@ const HeaderUnconnected: FC<IProps> = ({ user: { username, is_user, photo }, sho
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const Header = connect(
|
||||
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 { selectPlayer } from '~/redux/player/selectors';
|
||||
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
||||
|
@ -14,7 +14,7 @@ const mapStateToProps = state => ({
|
|||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
playerSetFile: PLAYER_ACTIONS.playerSetFile,
|
||||
playerSetFileAndPlay: PLAYER_ACTIONS.playerSetFileAndPlay,
|
||||
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||
playerPause: PLAYER_ACTIONS.playerPause,
|
||||
playerSeek: PLAYER_ACTIONS.playerSeek,
|
||||
|
@ -25,17 +25,21 @@ type Props = ReturnType<typeof mapStateToProps> &
|
|||
file: IFile;
|
||||
};
|
||||
|
||||
const AudioPlayerUnconnected = ({
|
||||
const AudioPlayerUnconnected = memo(
|
||||
({
|
||||
file,
|
||||
player: { file: current, status },
|
||||
|
||||
playerSetFile,
|
||||
playerSetFileAndPlay,
|
||||
playerPlay,
|
||||
playerPause,
|
||||
playerSeek,
|
||||
}: Props) => {
|
||||
}: Props) => {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState<IPlayerProgress>({ progress: 0, current: 0, total: 0 });
|
||||
const [progress, setProgress] = useState<IPlayerProgress>({
|
||||
progress: 0,
|
||||
current: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const onPlay = useCallback(() => {
|
||||
if (current && current.id === file.id) {
|
||||
|
@ -43,8 +47,8 @@ const AudioPlayerUnconnected = ({
|
|||
return playerPlay();
|
||||
}
|
||||
|
||||
playerSetFile(file);
|
||||
}, [file, current, status, playerPlay, playerPause, playerSetFile]);
|
||||
playerSetFileAndPlay(file);
|
||||
}, [file, current, status, playerPlay, playerPause, playerSetFileAndPlay]);
|
||||
|
||||
const onProgress = useCallback(
|
||||
({ detail }: { detail: IPlayerProgress }) => {
|
||||
|
@ -83,17 +87,23 @@ const AudioPlayerUnconnected = ({
|
|||
return (
|
||||
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
|
||||
<div className={styles.playpause}>
|
||||
{playing && status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
|
||||
{playing && status === PLAYER_STATES.PLAYING ? (
|
||||
<Icon icon="pause" />
|
||||
) : (
|
||||
<Icon icon="play" />
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<div className={styles.title}>{title || 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const AudioPlayer = connect(
|
||||
mapStateToProps,
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: $comment_height;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
flex: 1;
|
||||
|
||||
&:global(.playing) {
|
||||
.progress {
|
||||
|
@ -93,7 +98,8 @@
|
|||
}
|
||||
|
||||
.bar {
|
||||
background: linear-gradient(270deg, $green, $wisegreen);
|
||||
// background: linear-gradient(270deg, $green, $wisegreen);
|
||||
background: $main_gradient;
|
||||
position: absolute;
|
||||
height: 10px;
|
||||
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 { 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 { 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> & {
|
||||
is_empty?: boolean;
|
||||
is_loading?: boolean;
|
||||
comment?: IComment;
|
||||
comment_group?: ICommentGroup;
|
||||
is_same?: boolean;
|
||||
};
|
||||
|
||||
const Comment: FC<IProps> = ({ comment, is_empty, is_same, is_loading, className, ...props }) => {
|
||||
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||
() =>
|
||||
reduce(
|
||||
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
||||
{},
|
||||
comment.files
|
||||
),
|
||||
[comment]
|
||||
);
|
||||
|
||||
const Comment: FC<IProps> = memo(
|
||||
({ comment_group, is_empty, is_same, is_loading, className, ...props }) => {
|
||||
return (
|
||||
<CommentWrapper
|
||||
className={className}
|
||||
is_empty={is_empty}
|
||||
is_loading={is_loading}
|
||||
photo={getURL(comment.user.photo)}
|
||||
user={comment_group.user}
|
||||
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 className={styles.wrap}>
|
||||
{comment_group.comments.map(comment => (
|
||||
<CommentContent comment={comment} key={comment.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupped.audio && (
|
||||
<div className={styles.audios}>
|
||||
{groupped.audio.map(file => (
|
||||
<AudioPlayer key={file.id} file={file} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CommentWrapper>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export { Comment };
|
||||
|
|
|
@ -1,48 +1,11 @@
|
|||
@import 'flexbin/flexbin.scss';
|
||||
|
||||
.text {
|
||||
// @include outer_shadow();
|
||||
|
||||
padding: $gap;
|
||||
font-weight: 300;
|
||||
font: $font_16_medium;
|
||||
min-height: $comment_height;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
color: #cccccc;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
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;
|
||||
}
|
||||
.wrap {
|
||||
animation: appear 1s;
|
||||
}
|
||||
|
|
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> = ({
|
||||
node: { comment_data, is_sending_comment },
|
||||
uploads: { statuses, files },
|
||||
user: { photo },
|
||||
user,
|
||||
id,
|
||||
nodePostComment,
|
||||
nodeSetCommentData,
|
||||
|
@ -122,7 +122,7 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
const comment = comment_data[id];
|
||||
|
||||
return (
|
||||
<CommentWrapper photo={getURL(photo)}>
|
||||
<CommentWrapper user={user}>
|
||||
<form onSubmit={onSubmit} className={styles.wrap}>
|
||||
<div className={styles.input}>
|
||||
<Textarea
|
||||
|
@ -134,6 +134,22 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
/>
|
||||
</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}>
|
||||
<ButtonGroup>
|
||||
<Button iconLeft="image" size="small" grey iconOnly>
|
||||
|
@ -154,22 +170,6 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
</Button>
|
||||
</Group>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
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 { Filler } from '~/components/containers/Filler';
|
||||
|
||||
import * as styles from './styles.scss';
|
||||
import { ICommentGroup, IComment } from '~/redux/types';
|
||||
import { groupCommentsByUser } from '~/utils/fn';
|
||||
|
||||
interface IProps {
|
||||
comments?: any;
|
||||
comments?: IComment[];
|
||||
}
|
||||
|
||||
const isSameComment = (comments, index) =>
|
||||
comments[index - 1] && comments[index - 1].user.id === comments[index].user.id;
|
||||
const NodeComments: FC<IProps> = memo(({ comments }) => {
|
||||
const groupped: ICommentGroup[] = useMemo(() => comments.reduce(groupCommentsByUser, []), [
|
||||
comments,
|
||||
]);
|
||||
|
||||
const NodeComments: FC<IProps> = ({ comments }) => (
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
{comments.map((comment, index) => (
|
||||
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
|
||||
{groupped.map(group => (
|
||||
<Comment key={group.ids.join()} comment_group={group} />
|
||||
))}
|
||||
|
||||
<Filler />
|
||||
</div>
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
export { NodeComments };
|
||||
|
|
|
@ -1,20 +1,9 @@
|
|||
.wrap {
|
||||
& > 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,15 +1,25 @@
|
|||
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 { INode } from '~/redux/types';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NodePanelInner } from '~/components/node/NodePanelInner';
|
||||
import pick from 'ramda/es/pick';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
node: Partial<INode>;
|
||||
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(
|
||||
({ node, layout, can_edit, can_like, can_star, onEdit, onLike, onStar }) => {
|
||||
const [stack, setStack] = useState(false);
|
||||
|
||||
const ref = useRef(null);
|
||||
|
@ -37,12 +47,33 @@ const NodePanel: FC<IProps> = ({ node, layout }) => {
|
|||
return (
|
||||
<div className={styles.place} ref={ref}>
|
||||
{stack ? (
|
||||
createPortal(<NodePanelInner node={node} stack />, document.body)
|
||||
createPortal(
|
||||
<NodePanelInner
|
||||
node={node}
|
||||
stack
|
||||
onEdit={onEdit}
|
||||
onLike={onLike}
|
||||
onStar={onStar}
|
||||
can_edit={can_edit}
|
||||
can_like={can_like}
|
||||
can_star={can_star}
|
||||
/>,
|
||||
document.body
|
||||
)
|
||||
) : (
|
||||
<NodePanelInner node={node} />
|
||||
<NodePanelInner
|
||||
node={node}
|
||||
onEdit={onEdit}
|
||||
onLike={onLike}
|
||||
onStar={onStar}
|
||||
can_edit={can_edit}
|
||||
can_like={can_like}
|
||||
can_star={can_star}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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 { Group } from '~/components/containers/Group';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
|
@ -7,27 +7,61 @@ import { INode } from '~/redux/types';
|
|||
import classNames from 'classnames';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
node: Partial<INode>;
|
||||
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 (
|
||||
<div className={classNames(styles.wrap, { stack })}>
|
||||
<div className={styles.content}>
|
||||
<Group horizontal className={styles.panel}>
|
||||
<Filler>
|
||||
<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>
|
||||
</Group>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<Icon icon="edit" size={24} />
|
||||
|
||||
<div className={styles.sep} />
|
||||
|
||||
<Icon icon="heart" size={24} />
|
||||
{can_star && (
|
||||
<div className={classNames(styles.star, { is_heroic })}>
|
||||
{is_heroic ? (
|
||||
<Icon icon="star_full" size={24} onClick={onStar} />
|
||||
) : (
|
||||
<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>
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
padding: $gap;
|
||||
background: $node_bg;
|
||||
height: 72px;
|
||||
@include outer_shadow();
|
||||
}
|
||||
|
||||
.title {
|
||||
|
@ -65,22 +66,43 @@
|
|||
|
||||
& > * {
|
||||
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 {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
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 {
|
||||
|
@ -94,16 +116,68 @@
|
|||
right: 4px;
|
||||
width: 24px;
|
||||
height: 52px;
|
||||
background: $green_gradient;
|
||||
background: $main_gradient;
|
||||
box-shadow: transparentize(black, 0.8) 4px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: 0 0 6px;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 4px;
|
||||
background: transparentize(black, 0.7);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
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,13 +1,16 @@
|
|||
import React, { FC, HTMLAttributes } from 'react';
|
||||
import { range } from 'ramda';
|
||||
import React, { FC } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
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> = ({
|
||||
title,
|
||||
}) => (
|
||||
const NodeRelated: FC<IProps> = ({ title, items }) => {
|
||||
return (
|
||||
<Group className={styles.wrap}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.line} />
|
||||
|
@ -15,11 +18,12 @@ const NodeRelated: FC<IProps> = ({
|
|||
<div className={styles.line} />
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{
|
||||
range(1, 7).map(el => (<div className={styles.item} key={el} />))
|
||||
}
|
||||
{items.map(item => (
|
||||
<NodeRelatedItem item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeRelated };
|
||||
|
|
|
@ -7,19 +7,16 @@
|
|||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: auto;
|
||||
grid-column-gap: $gap;
|
||||
grid-row-gap: $gap;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: darken($content_bg, 2%);
|
||||
padding-bottom: 100%;
|
||||
border-radius: $cell_radius;
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font: $font_14_semibold;
|
||||
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 { ITag } from '~/redux/types';
|
||||
|
||||
|
@ -8,8 +8,8 @@ interface IProps {
|
|||
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} />
|
||||
);
|
||||
));
|
||||
|
||||
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 {
|
||||
@include outer_shadow();
|
||||
|
||||
height: $tag_height;
|
||||
background: $tag_bg;
|
||||
display: flex;
|
||||
|
@ -9,8 +11,8 @@
|
|||
font: $font_14_semibold;
|
||||
align-self: flex-start;
|
||||
padding: 0 8px 0 0;
|
||||
box-shadow: $shadow_depth_2;
|
||||
margin: ($gap / 2) $gap ($gap / 2) 0;
|
||||
// box-shadow: $shadow_depth_2;
|
||||
margin: 0 $gap $gap 0;
|
||||
position: relative;
|
||||
|
||||
&:global(.is_hoverable) {
|
||||
|
@ -64,7 +66,7 @@
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding-left: 23px;
|
||||
padding-left: $tag_height;
|
||||
padding-right: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -74,10 +76,10 @@
|
|||
width: $tag_height;
|
||||
height: $tag_height;
|
||||
display: flex;
|
||||
margin-right: 3px;
|
||||
// padding-right: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 22px;
|
||||
flex: 0 0 $tag_height;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
|
|
|
@ -12,6 +12,7 @@ import { TagField } from '~/components/containers/TagField';
|
|||
import { ITag } from '~/redux/types';
|
||||
import { Tag } from '~/components/node/Tag';
|
||||
import uniq from 'ramda/es/uniq';
|
||||
import assocPath from 'ramda/es/assocPath';
|
||||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||
tags: Partial<ITag>[];
|
||||
|
@ -65,9 +66,14 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, ...props })
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
if (!data.length) return;
|
||||
onTagsChange(uniq([...tags, ...data]).map(tag => tag.title));
|
||||
}, [tags, data, onTagsChange]);
|
||||
const title = input && input.trim();
|
||||
const items = title ? [...data, { title }] : data;
|
||||
|
||||
if (!items.length) return;
|
||||
setData(items);
|
||||
setInput('');
|
||||
onTagsChange(uniq([...tags, ...items]).map(tag => tag.title));
|
||||
}, [tags, data, onTagsChange, input, setInput]);
|
||||
|
||||
useEffect(() => {
|
||||
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,18 +1,33 @@
|
|||
import React, { FC } from 'react';
|
||||
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?: string;
|
||||
id?: IFile['id'];
|
||||
thumb?: string;
|
||||
progress?: number;
|
||||
onDrop?: (file_id: IFile['id']) => void;
|
||||
|
||||
is_uploading?: boolean;
|
||||
}
|
||||
|
||||
const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading }) => (
|
||||
const ImageUpload: FC<IProps> = ({ thumb, 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 })}>
|
||||
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
|
||||
{is_uploading && (
|
||||
|
@ -22,6 +37,7 @@ const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading }) => (
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export { ImageUpload };
|
||||
|
|
|
@ -57,3 +57,29 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,10 @@ export const API = {
|
|||
GET_NODE: (id: number | string) => `/node/${id}`,
|
||||
|
||||
COMMENT: (id: INode['id']) => `/node/${id}/comment`,
|
||||
RELATED: (id: INode['id']) => `/node/${id}/related`,
|
||||
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',
|
||||
EMPTY_RESPONSE: 'Empty_Response',
|
||||
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 = {
|
||||
[ERRORS.NOT_AN_EMAIL]: 'Введите правильный e-mail',
|
||||
[ERRORS.TOO_SHIRT]: 'Слишком короткий',
|
||||
[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 { selectModal } from '~/redux/modal/selectors';
|
||||
import { BlurWrapper } from '~/components/containers/BlurWrapper';
|
||||
import { PageCover } from '~/components/containers/PageCover';
|
||||
import { NodeLayout } from './node/NodeLayout';
|
||||
import { BottomContainer } from '~/containers/main/BottomContainer';
|
||||
|
||||
const mapStateToProps = selectModal;
|
||||
const mapStateToProps = state => ({
|
||||
modal: selectModal(state),
|
||||
});
|
||||
const mapDispatchToProps = {};
|
||||
|
||||
type IProps = typeof mapDispatchToProps & ReturnType<typeof mapStateToProps> & {};
|
||||
|
||||
const Component: FC<IProps> = ({ is_shown }) => (
|
||||
const Component: FC<IProps> = ({ modal: { is_shown } }) => (
|
||||
<ConnectedRouter history={history}>
|
||||
<div>
|
||||
<BlurWrapper is_blurred={is_shown}>
|
||||
<PageCover />
|
||||
<MainLayout>
|
||||
<Modal />
|
||||
<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 assocPath from 'ramda/es/assocPath';
|
||||
import append from 'ramda/es/append';
|
||||
import uuid from 'uuid4';
|
||||
import { ScrollDialog } from '../ScrollDialog';
|
||||
import { IDialogProps } from '~/redux/modal/constants';
|
||||
import { useCloseOnEscape } from '~/utils/hooks';
|
||||
|
@ -12,117 +9,42 @@ import { Button } from '~/components/input/Button';
|
|||
import { Padder } from '~/components/containers/Padder';
|
||||
import * as styles from './styles.scss';
|
||||
import { selectNode } from '~/redux/node/selectors';
|
||||
import { ImageEditor } from '~/components/editors/ImageEditor';
|
||||
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 { 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 { editor } = selectNode(state);
|
||||
const { editor, errors } = selectNode(state);
|
||||
const { statuses, files } = selectUploads(state);
|
||||
|
||||
return { editor, statuses, files };
|
||||
return { editor, statuses, files, errors };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
|
||||
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> = ({
|
||||
onRequestClose,
|
||||
editor,
|
||||
files,
|
||||
statuses,
|
||||
|
||||
uploadUploadFiles,
|
||||
errors,
|
||||
nodeSave,
|
||||
nodeSetSaveErrors,
|
||||
onRequestClose,
|
||||
type,
|
||||
}) => {
|
||||
const [data, setData] = useState(editor);
|
||||
const eventPreventer = useCallback(event => event.preventDefault(), []);
|
||||
const [data, setData] = useState(EMPTY_NODE);
|
||||
const [temp, setTemp] = useState([]);
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
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]);
|
||||
useEffect(() => setData(editor), [editor]);
|
||||
|
||||
const setTitle = useCallback(
|
||||
title => {
|
||||
|
@ -139,9 +61,18 @@ const EditorDialogUnconnected: FC<IProps> = ({
|
|||
[data, nodeSave]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!NODE_EDITORS[type] && onRequestClose) onRequestClose();
|
||||
}, [type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Object.keys(errors).length) return;
|
||||
nodeSetSaveErrors({});
|
||||
}, [data]);
|
||||
|
||||
const buttons = (
|
||||
<Padder style={{ position: 'relative' }}>
|
||||
<EditorPanel data={data} setData={setData} onUpload={onInputChange} />
|
||||
<EditorPanel data={data} setData={setData} temp={temp} setTemp={setTemp} />
|
||||
|
||||
<Group horizontal>
|
||||
<InputText title="Название" value={data.title} handler={setTitle} autoFocus />
|
||||
|
@ -153,18 +84,25 @@ const EditorDialogUnconnected: FC<IProps> = ({
|
|||
|
||||
useCloseOnEscape(onRequestClose);
|
||||
|
||||
const error = errors && Object.values(errors)[0];
|
||||
|
||||
if (!NODE_EDITORS[type]) return null;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className={styles.form}>
|
||||
<ScrollDialog buttons={buttons} width={860} onClose={onRequestClose}>
|
||||
<div className={styles.editor} onDrop={onDrop}>
|
||||
<ImageEditor
|
||||
data={data}
|
||||
pending_files={temp.filter(id => !!statuses[id]).map(id => statuses[id])}
|
||||
setData={setData}
|
||||
onUpload={onInputChange}
|
||||
onFileMove={onFileMove}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
<ScrollDialog
|
||||
buttons={buttons}
|
||||
width={860}
|
||||
error={error && ERROR_LITERAL[error]}
|
||||
onClose={onRequestClose}
|
||||
>
|
||||
<div className={styles.editor}>
|
||||
{createElement(NODE_EDITORS[type], {
|
||||
data,
|
||||
setData,
|
||||
temp,
|
||||
setTemp,
|
||||
})}
|
||||
</div>
|
||||
</ScrollDialog>
|
||||
</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], {
|
||||
onRequestClose,
|
||||
onDialogChange: modalShowDialog,
|
||||
} as IDialogProps)}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const Modal = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mapDispatchToProps
|
||||
)(ModalUnconnected);
|
||||
|
||||
export { ModalUnconnected, Modal };
|
||||
|
|
|
@ -133,7 +133,7 @@
|
|||
}
|
||||
|
||||
.error {
|
||||
background: linear-gradient(transparentize($orange, 1), $red);
|
||||
background: linear-gradient(transparentize($orange, 1), $red 90%);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
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 { selectFlow } from '~/redux/flow/selectors';
|
||||
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 & {};
|
||||
|
||||
const FlowLayoutUnconnected: FC<IProps> = ({ nodes, nodeLoadNode }) => (
|
||||
<FlowGrid nodes={nodes} onSelect={nodeLoadNode} />
|
||||
const FlowLayoutUnconnected: FC<IProps> = ({
|
||||
flow: { nodes, heroes },
|
||||
user,
|
||||
nodeLoadNode,
|
||||
flowSetCellView,
|
||||
}) => (
|
||||
<FlowGrid
|
||||
nodes={nodes}
|
||||
heroes={heroes}
|
||||
onSelect={nodeLoadNode}
|
||||
user={user}
|
||||
onChangeCellView={flowSetCellView}
|
||||
/>
|
||||
);
|
||||
|
||||
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