mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 21:06:42 +07:00
Merge branch 'master' of https://github.com/muerwre/vault-frontend
This commit is contained in:
commit
d6ff3bcdca
142 changed files with 6877 additions and 3841 deletions
|
@ -1,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,
|
||||
}) => (
|
||||
<div className={styles.wrap}>
|
||||
<input type="file" onChange={onUpload} accept="image/*" multiple />
|
||||
return { statuses, files };
|
||||
};
|
||||
|
||||
<div className={styles.icon}>
|
||||
<Icon size={32} icon="plus" />
|
||||
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={onInputChange} accept={accept} multiple />
|
||||
|
||||
<div className={styles.icon}>
|
||||
<Icon size={32} icon={icon} />
|
||||
</div>
|
||||
</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;
|
||||
|
@ -35,4 +37,4 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
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 {
|
||||
.horizontal,
|
||||
.quadro {
|
||||
grid-column-end: span 2;
|
||||
}
|
||||
|
||||
.vertical,
|
||||
.quadro {
|
||||
grid-row-end: span 2;
|
||||
}
|
||||
|
||||
@media (max-width: $cell * 2) {
|
||||
.horizontal,
|
||||
.quadro,
|
||||
.vertical,
|
||||
.quadro {
|
||||
grid-row-end: span 1;
|
||||
}
|
||||
|
||||
.vert-2 {
|
||||
grid-row-end: span 2;
|
||||
}
|
||||
|
||||
.hor-1 {
|
||||
grid-column-end: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hor-2 {
|
||||
grid-column-end: span 2;
|
||||
}
|
||||
|
||||
.is_text {
|
||||
background: none;
|
||||
padding: 10px;
|
||||
box-shadow: inset #444 0 0 0 1px;
|
||||
}
|
||||
.is_text {
|
||||
background: none;
|
||||
padding: 10px;
|
||||
box-shadow: inset #444 0 0 0 1px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
|
@ -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,44 +22,48 @@ type IButtonProps = DetailedHTMLProps<
|
|||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export const Button: FC<IButtonProps> = ({
|
||||
className = '',
|
||||
size = 'normal',
|
||||
iconLeft,
|
||||
iconRight,
|
||||
children,
|
||||
seamless = false,
|
||||
transparent = false,
|
||||
non_submitting = false,
|
||||
red = false,
|
||||
grey = false,
|
||||
is_loading,
|
||||
title,
|
||||
stretchy,
|
||||
disabled,
|
||||
iconOnly,
|
||||
...props
|
||||
}) =>
|
||||
createElement(
|
||||
seamless || non_submitting ? 'div' : 'button',
|
||||
{
|
||||
className: classnames(styles.button, className, styles[size], {
|
||||
red,
|
||||
grey,
|
||||
seamless,
|
||||
transparent,
|
||||
disabled,
|
||||
is_loading,
|
||||
stretchy,
|
||||
icon: ((iconLeft || iconRight) && !title && !children) || iconOnly,
|
||||
has_icon_left: !!iconLeft,
|
||||
has_icon_right: !!iconRight,
|
||||
}),
|
||||
...props,
|
||||
},
|
||||
[
|
||||
iconLeft && <Icon icon={iconLeft} size={20} key={0} />,
|
||||
title ? <span>{title}</span> : children || null,
|
||||
iconRight && <Icon icon={iconRight} size={20} key={2} />,
|
||||
]
|
||||
);
|
||||
const Button: FC<IButtonProps> = memo(
|
||||
({
|
||||
className = '',
|
||||
size = 'normal',
|
||||
iconLeft,
|
||||
iconRight,
|
||||
children,
|
||||
seamless = false,
|
||||
transparent = false,
|
||||
non_submitting = false,
|
||||
red = false,
|
||||
grey = false,
|
||||
is_loading,
|
||||
title,
|
||||
stretchy,
|
||||
disabled,
|
||||
iconOnly,
|
||||
...props
|
||||
}) =>
|
||||
createElement(
|
||||
seamless || non_submitting ? 'div' : 'button',
|
||||
{
|
||||
className: classnames(styles.button, className, styles[size], {
|
||||
red,
|
||||
grey,
|
||||
seamless,
|
||||
transparent,
|
||||
disabled,
|
||||
is_loading,
|
||||
stretchy,
|
||||
icon: ((iconLeft || iconRight) && !title && !children) || iconOnly,
|
||||
has_icon_left: !!iconLeft,
|
||||
has_icon_right: !!iconRight,
|
||||
}),
|
||||
...props,
|
||||
},
|
||||
[
|
||||
iconLeft && <Icon icon={iconLeft} size={20} key={0} />,
|
||||
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,75 +25,85 @@ type Props = ReturnType<typeof mapStateToProps> &
|
|||
file: IFile;
|
||||
};
|
||||
|
||||
const AudioPlayerUnconnected = ({
|
||||
file,
|
||||
player: { file: current, status },
|
||||
const AudioPlayerUnconnected = memo(
|
||||
({
|
||||
file,
|
||||
player: { file: current, status },
|
||||
playerSetFileAndPlay,
|
||||
playerPlay,
|
||||
playerPause,
|
||||
playerSeek,
|
||||
}: Props) => {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState<IPlayerProgress>({
|
||||
progress: 0,
|
||||
current: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
playerSetFile,
|
||||
playerPlay,
|
||||
playerPause,
|
||||
playerSeek,
|
||||
}: Props) => {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState<IPlayerProgress>({ progress: 0, current: 0, total: 0 });
|
||||
const onPlay = useCallback(() => {
|
||||
if (current && current.id === file.id) {
|
||||
if (status === PLAYER_STATES.PLAYING) return playerPause();
|
||||
return playerPlay();
|
||||
}
|
||||
|
||||
const onPlay = useCallback(() => {
|
||||
if (current && current.id === file.id) {
|
||||
if (status === PLAYER_STATES.PLAYING) return playerPause();
|
||||
return playerPlay();
|
||||
}
|
||||
playerSetFileAndPlay(file);
|
||||
}, [file, current, status, playerPlay, playerPause, playerSetFileAndPlay]);
|
||||
|
||||
playerSetFile(file);
|
||||
}, [file, current, status, playerPlay, playerPause, playerSetFile]);
|
||||
const onProgress = useCallback(
|
||||
({ detail }: { detail: IPlayerProgress }) => {
|
||||
if (!detail || !detail.total) return;
|
||||
setProgress(detail);
|
||||
},
|
||||
[setProgress]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const onSeek = useCallback(
|
||||
event => {
|
||||
event.stopPropagation();
|
||||
const { clientX, target } = event;
|
||||
const { left, width } = target.getBoundingClientRect();
|
||||
playerSeek((clientX - left) / width);
|
||||
},
|
||||
[playerSeek]
|
||||
);
|
||||
useEffect(() => {
|
||||
const active = current && current.id === file.id;
|
||||
setPlaying(current && current.id === file.id);
|
||||
|
||||
useEffect(() => {
|
||||
const active = current && current.id === file.id;
|
||||
setPlaying(current && current.id === file.id);
|
||||
if (active) Player.on('playprogress', onProgress);
|
||||
|
||||
if (active) Player.on('playprogress', onProgress);
|
||||
return () => {
|
||||
if (active) Player.off('playprogress', onProgress);
|
||||
};
|
||||
}, [file, current, setPlaying, onProgress]);
|
||||
|
||||
return () => {
|
||||
if (active) Player.off('playprogress', onProgress);
|
||||
};
|
||||
}, [file, current, setPlaying, onProgress]);
|
||||
const title =
|
||||
file.metadata &&
|
||||
(file.metadata.title ||
|
||||
[file.metadata.id3artist, file.metadata.id3title].filter(el => !!el).join(' - '));
|
||||
|
||||
const title =
|
||||
file.metadata &&
|
||||
(file.metadata.title ||
|
||||
[file.metadata.id3artist, file.metadata.id3title].filter(el => !!el).join(' - '));
|
||||
|
||||
return (
|
||||
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
|
||||
<div className={styles.playpause}>
|
||||
{playing && status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.progress} onClick={onSeek}>
|
||||
<div className={styles.bar} style={{ width: `${progress.progress}%` }} />
|
||||
return (
|
||||
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
|
||||
<div className={styles.playpause}>
|
||||
{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>
|
||||
<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]
|
||||
);
|
||||
|
||||
return (
|
||||
<CommentWrapper
|
||||
className={className}
|
||||
is_empty={is_empty}
|
||||
is_loading={is_loading}
|
||||
photo={getURL(comment.user.photo)}
|
||||
is_same={is_same}
|
||||
{...props}
|
||||
>
|
||||
{comment.text && (
|
||||
<Group
|
||||
className={styles.text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatCommentText(
|
||||
!is_same && comment.user && comment.user.username,
|
||||
comment.text
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
|
||||
{groupped.image && (
|
||||
<div className={styles.images}>
|
||||
{groupped.image.map(file => (
|
||||
<div key={file.id}>
|
||||
<img src={getURL(file)} alt={file.name} />
|
||||
</div>
|
||||
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}
|
||||
user={comment_group.user}
|
||||
is_same={is_same}
|
||||
{...props}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
</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 }) => (
|
||||
<div className={styles.wrap}>
|
||||
{comments.map((comment, index) => (
|
||||
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
|
||||
))}
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
{groupped.map(group => (
|
||||
<Comment key={group.ids.join()} comment_group={group} />
|
||||
))}
|
||||
|
||||
<Filler />
|
||||
</div>
|
||||
);
|
||||
<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,48 +1,79 @@
|
|||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useRef, useState, memo } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { 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 [stack, setStack] = useState(false);
|
||||
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);
|
||||
const getPlace = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
const ref = useRef(null);
|
||||
const getPlace = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const { offsetTop } = ref.current;
|
||||
const { height } = ref.current.getBoundingClientRect();
|
||||
const { scrollY, innerHeight } = window;
|
||||
const { offsetTop } = ref.current;
|
||||
const { height } = ref.current.getBoundingClientRect();
|
||||
const { scrollY, innerHeight } = window;
|
||||
|
||||
setStack(offsetTop > scrollY + innerHeight - height);
|
||||
}, [ref]);
|
||||
setStack(offsetTop > scrollY + innerHeight - height);
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
getPlace();
|
||||
window.addEventListener('scroll', getPlace);
|
||||
window.addEventListener('resize', getPlace);
|
||||
useEffect(() => {
|
||||
getPlace();
|
||||
window.addEventListener('scroll', getPlace);
|
||||
window.addEventListener('resize', getPlace);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', getPlace);
|
||||
window.removeEventListener('resize', getPlace);
|
||||
};
|
||||
}, [layout]);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', getPlace);
|
||||
window.removeEventListener('resize', getPlace);
|
||||
};
|
||||
}, [layout]);
|
||||
|
||||
return (
|
||||
<div className={styles.place} ref={ref}>
|
||||
{stack ? (
|
||||
createPortal(<NodePanelInner node={node} stack />, document.body)
|
||||
) : (
|
||||
<NodePanelInner node={node} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className={styles.place} ref={ref}>
|
||||
{stack ? (
|
||||
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}
|
||||
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,25 +1,29 @@
|
|||
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,
|
||||
}) => (
|
||||
<Group className={styles.wrap}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.line} />
|
||||
<div className={styles.text}>{title}</div>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{
|
||||
range(1, 7).map(el => (<div className={styles.item} key={el} />))
|
||||
}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
const NodeRelated: FC<IProps> = ({ title, items }) => {
|
||||
return (
|
||||
<Group className={styles.wrap}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.line} />
|
||||
<div className={styles.text}>{title}</div>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{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,27 +1,43 @@
|
|||
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 }) => (
|
||||
<div className={styles.wrap}>
|
||||
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
|
||||
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
|
||||
{is_uploading && (
|
||||
<div className={styles.progress}>
|
||||
<ArcProgress size={72} progress={progress} />
|
||||
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 && (
|
||||
<div className={styles.progress}>
|
||||
<ArcProgress size={72} progress={progress} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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(
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
import React, { FC } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { PlayerBar } from '~/components/bars/PlayerBar';
|
||||
import { SubmitBar } from '~/components/bars/SubmitBar';
|
||||
import { selectUser } from '~/redux/auth/selectors';
|
||||
import pick from 'ramda/es/pick';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
interface IProps {}
|
||||
const mapStateToProps = state => pick(['is_user'], selectUser(state));
|
||||
|
||||
const BottomContainer: FC<IProps> = ({}) => (
|
||||
type IProps = ReturnType<typeof mapStateToProps> & {};
|
||||
|
||||
const BottomContainerUnconnected: FC<IProps> = ({ is_user }) => (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.content}>
|
||||
<PlayerBar />
|
||||
|
||||
{is_user && <SubmitBar />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BottomContainer = connect(mapStateToProps)(BottomContainerUnconnected);
|
||||
export { BottomContainer };
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
.wrap {
|
||||
position: fixed;
|
||||
transform: translateZ(0);
|
||||
bottom: $gap;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
height: 54px;
|
||||
height: $bar_height;
|
||||
display: flex;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
|
@ -18,7 +18,7 @@
|
|||
.content {
|
||||
position: relative;
|
||||
flex: 0 1 $content_width;
|
||||
height: 48px;
|
||||
height: $bar_height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
@include tablet {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
|
@ -14,6 +18,6 @@
|
|||
width: 100%;
|
||||
max-width: $content_width;
|
||||
display: flex;
|
||||
padding-bottom: 10px;
|
||||
padding-bottom: 64px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
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