1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 04:46:40 +07:00
This commit is contained in:
muerwre 2019-10-25 20:44:32 +07:00
commit d6ff3bcdca
142 changed files with 6877 additions and 3841 deletions

View file

@ -17,4 +17,5 @@
"editor.formatOnSave": true,
"editor.formatOnSaveTimeout": 750,
},
"typescript.tsdk": "node_modules/typescript/lib",
}

5674
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,10 +14,10 @@
"url": "https://github.com/muerwre/my-empty-react-project"
},
"devDependencies": {
"@babel/cli": "^7.6.3",
"@babel/cli": "^7.6.4",
"@babel/preset-env": "^7.6.3",
"@babel/types": "7.5.5",
"@types/react-router": "^5.0.3",
"@types/react-router": "^5.1.2",
"autoresponsive-react": "^1.1.31",
"awesome-typescript-loader": "^5.2.1",
"babel-core": "^6.26.3",
@ -42,7 +42,7 @@
"ts-node": "^8.4.1",
"typescript": "^3.6.4",
"uglifyjs-webpack-plugin": "^1.3.0",
"webpack": "^4.41.0",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.9",
"webpack-dev-server": "^3.8.2"
},
@ -51,7 +51,7 @@
"@hot-loader/react-dom": "^16.10.2",
"@types/classnames": "^2.2.7",
"@types/node": "^11.13.22",
"@types/ramda": "^0.26.29",
"@types/ramda": "^0.26.33",
"@types/react": "16.8.23",
"@typescript-eslint/eslint-plugin": "^1.13.0",
"@typescript-eslint/parser": "^1.13.0",
@ -61,7 +61,7 @@
"clean-webpack-plugin": "^0.1.9",
"connected-react-router": "^6.3.2",
"date-fns": "^2.4.1",
"dotenv": "^8.1.0",
"dotenv": "^8.2.0",
"dotenv-webpack": "^1.7.0",
"eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.1",
@ -104,6 +104,7 @@
"sass-loader": "^7.3.1",
"sass-resources-loader": "^2.0.0",
"scrypt": "^6.0.3",
"sticky-sidebar": "^3.3.1",
"throttle-debounce": "^2.1.0",
"tslint": "^5.20.0",
"tslint-config-airbnb": "^5.11.2",

View file

@ -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>

View file

@ -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;
}

View 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 };

View 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;
}
}
}

View file

@ -1,4 +1,8 @@
.blur {
filter: blur(0);
transition: filter 0.25s;
will-change: filter;
// max-height: 100vh;
// width: 100vw;
// overflow: visible auto;
}

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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} />
);

View 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 };

View 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;
}
}

View file

@ -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 };

View 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 };

View file

@ -0,0 +1,4 @@
.wrap {
padding-bottom: $upload_button_height + $gap;
min-height: 200px;
}

View 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 };

View file

@ -0,0 +1,4 @@
.helper {
opacity: 0.5;
z-index: 10 !important;
}

View 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 };

View 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 };

View file

@ -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>
);

View file

@ -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;
}
}
}

View file

@ -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 };

View file

@ -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;
}
}

View 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 };

View 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;
}
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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}
/>

View file

@ -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;
}

View 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 };

View 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));
}
}

View 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 };

View file

@ -0,0 +1,4 @@
.item {
z-index: 1;
box-sizing: border-box;
}

View 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 };

View 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));
}
}

View 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 };

View file

@ -0,0 +1,4 @@
.item {
z-index: 1;
box-sizing: border-box;
}

View 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 };

View file

@ -0,0 +1,5 @@
.wrap {
& > div {
padding-bottom: 64px;
}
}

View 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 };

View 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;
}
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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}
// />
// ));
// }

View file

@ -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 {

View 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 };

View 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;
}
}
}

View file

@ -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;
}

View file

@ -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 };

View file

@ -108,6 +108,7 @@
&:global(.disabled),
&:global(.grey) {
background: transparentize(white, 0.9);
color: white;
// background: lighten(white, 0.5);
// filter: grayscale(100%);
}

View file

@ -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>
);
};

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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 };

View file

@ -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;
}

View 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 && (
)}
*/

View 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;
}
}

View file

@ -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>
);
};

View 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 };

View 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;
}
}
}

View 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 };

View 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;
}

View file

@ -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 };

View file

@ -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;
// }
// }
}

View 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 };

View 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;
}

View file

@ -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 };

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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 };

View file

@ -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;

View 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 };

View 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;
}
}

View file

@ -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 };

View 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 };

View 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;
}
}

View 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 };

View file

@ -0,0 +1,12 @@
.wrap {
padding-bottom: 56.25%;
position: relative;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
}

View file

@ -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: ' ';

View file

@ -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())));

View 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 };

View 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;
}
}

View file

@ -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 };

View file

@ -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;
}
}

View file

@ -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`,
},
};

View file

@ -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]: 'Недопустимые данные',
};

View file

@ -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 />

View file

@ -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>

View 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 };

View file

@ -0,0 +1,11 @@
.wrap {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
svg {
fill: white;
opacity: 0.5;
}
}

View file

@ -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 };

View file

@ -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;

View 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 };

View 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 };

View 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 };

View 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 };

View file

@ -3,15 +3,35 @@ import { connect } from 'react-redux';
import { FlowGrid } from '~/components/flow/FlowGrid';
import { selectFlow } from '~/redux/flow/selectors';
import * as NODE_ACTIONS from '~/redux/node/actions';
import * as FLOW_ACTIONS from '~/redux/flow/actions';
import pick from 'ramda/es/pick';
import { selectUser } from '~/redux/auth/selectors';
const mapStateToProps = selectFlow;
const mapStateToProps = state => ({
flow: pick(['nodes', 'heroes'], selectFlow(state)),
user: pick(['role', 'id'], selectUser(state)),
});
const mapDispatchToProps = { nodeLoadNode: NODE_ACTIONS.nodeLoadNode };
const mapDispatchToProps = {
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
flowSetCellView: FLOW_ACTIONS.flowSetCellView,
};
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
const FlowLayoutUnconnected: FC<IProps> = ({ nodes, nodeLoadNode }) => (
<FlowGrid nodes={nodes} onSelect={nodeLoadNode} />
const FlowLayoutUnconnected: FC<IProps> = ({
flow: { nodes, heroes },
user,
nodeLoadNode,
flowSetCellView,
}) => (
<FlowGrid
nodes={nodes}
heroes={heroes}
onSelect={nodeLoadNode}
user={user}
onChangeCellView={flowSetCellView}
/>
);
const FlowLayout = connect(

Some files were not shown because too many files have changed in this diff Show more