1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-07-07 16:58:29 +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
.vscode
package-lock.jsonpackage.json
src
components
bars
containers
BlurWrapper
CommentWrapper
Filler
PageCover
Scroll
editors
AudioEditor
AudioGrid
EditorAudioUploadButton
EditorImageUploadButton
EditorPanel
EditorUploadButton
EditorUploadCoverButton
ImageEditor
ImageGrid
SortableAudioGrid
SortableAudioGridItem
SortableImageGrid
SortableImageGridItem
TextEditor
VideoEditor
flow
input
main/Header
media/AudioPlayer
node
Comment
CommentContent
CommentForm
NodeAudioBlock
NodeAudioImageBlock
NodeComments
NodeImageSlideBlock
NodePanel
NodePanelInner
NodeRelated
NodeRelatedItem
NodeTags
NodeTextBlock
NodeVideoBlock
Tag
Tags
upload
constants
containers
App.tsx
dialogs
EditorDialog
LoadingDialog
Modal
ScrollDialog
editors
EditorDialogAudio
EditorDialogImage
EditorDialogText
EditorDialogVideo
flow/FlowLayout

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,
}) => (
return { statuses, files };
};
const mapDispatchToProps = {
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
};
type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
data: INode;
setData: (val: INode) => void;
temp: string[];
setTemp: (val: string[]) => void;
accept?: string;
icon?: string;
type?: typeof UPLOAD_TYPES[keyof typeof UPLOAD_TYPES];
};
const EditorUploadButtonUnconnected: FC<IProps> = ({
data,
setData,
temp,
setTemp,
statuses,
files,
uploadUploadFiles,
accept = 'image/*',
icon = 'plus',
type = UPLOAD_TYPES.IMAGE,
}) => {
const eventPreventer = useCallback(event => event.preventDefault(), []);
const onUpload = useCallback(
(uploads: File[]) => {
const current = temp.length + data.files.length;
const limit = NODE_SETTINGS.MAX_FILES - current;
if (current >= NODE_SETTINGS.MAX_FILES) return;
const items: IFileWithUUID[] = Array.from(uploads).map(
(file: File): IFileWithUUID => ({
file,
temp_id: uuid(),
subject: UPLOAD_SUBJECTS.EDITOR,
target: UPLOAD_TARGETS.NODES,
type,
})
);
const temps = items.map(file => file.temp_id).slice(0, limit);
setTemp([...temp, ...temps]);
uploadUploadFiles(items);
},
[setTemp, uploadUploadFiles, temp, data, type]
);
const onFileAdd = useCallback(
(file: IFile) => {
setData(assocPath(['files'], append(file, data.files), data));
},
[data, setData]
);
// const onDrop = useCallback(
// (event: React.DragEvent<HTMLDivElement>) => {
// event.preventDefault();
// if (!event.dataTransfer || !event.dataTransfer.files || !event.dataTransfer.files.length)
// return;
// onUpload(Array.from(event.dataTransfer.files));
// },
// [onUpload]
// );
useEffect(() => {
window.addEventListener('dragover', eventPreventer, false);
window.addEventListener('drop', eventPreventer, false);
return () => {
window.removeEventListener('dragover', eventPreventer, false);
window.removeEventListener('drop', eventPreventer, false);
};
}, [eventPreventer]);
useEffect(() => {
Object.entries(statuses).forEach(([id, status]) => {
if (temp.includes(id) && !!status.uuid && files[status.uuid]) {
onFileAdd(files[status.uuid]);
setTemp(temp.filter(el => el !== id));
}
});
}, [statuses, files, temp, onFileAdd]);
const onInputChange = useCallback(
event => {
event.preventDefault();
if (!event.target.files || !event.target.files.length) return;
onUpload(Array.from(event.target.files));
},
[onUpload]
);
return (
<div className={styles.wrap}>
<input type="file" onChange={onUpload} accept="image/*" multiple />
<input type="file" onChange={onInputChange} accept={accept} multiple />
<div className={styles.icon}>
<Icon size={32} icon="plus" />
<Icon size={32} icon={icon} />
</div>
</div>
);
);
};
const EditorUploadButton = connect(
mapStateToProps,
mapDispatchToProps
)(EditorUploadButtonUnconnected);
export { EditorUploadButton };

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;

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 {
grid-row-end: span 1;
}
.horizontal,
.quadro {
grid-column-end: span 2;
}
.vert-2 {
.vertical,
.quadro {
grid-row-end: span 2;
}
}
.hor-1 {
@media (max-width: $cell * 2) {
.horizontal,
.quadro,
.vertical,
.quadro {
grid-row-end: span 1;
grid-column-end: span 1;
}
}
.hor-2 {
grid-column-end: span 2;
}
.is_text {
.is_text {
background: none;
padding: 10px;
box-shadow: inset #444 0 0 0 1px;
}
}
.thumbnail {
@ -106,6 +107,7 @@
border-radius: $cell_radius + 2px;
opacity: 0;
transition: opacity 0.5s;
will-change: transform;
& > img {
opacity: 0;
@ -115,6 +117,10 @@
}
.face {
@include outer_shadow();
display: flex;
overflow: hidden;
box-sizing: border-box;
position: absolute;
top: 0;
@ -125,4 +131,154 @@
z-index: 2;
border-radius: $cell_radius;
padding: $gap;
pointer-events: none;
touch-action: none;
@media (min-width: $cell * 2 + $grid_line) {
.vertical > &.has_text,
.horizontal > &.has_text,
.quadro > &.has_text {
box-sizing: border-box;
background: none;
box-shadow: none;
padding: $grid_line;
&::after {
display: none;
}
.face_content {
padding: $gap;
background: rgba(25, 25, 25, 0.8);
border-radius: $radius;
overflow: hidden;
}
.text::after {
display: none;
}
}
.vertical > &.has_text {
top: auto;
bottom: 0;
height: 50%;
max-width: 100%;
// height: auto;
width: auto;
padding: ($grid_line / 2) $grid_line $grid_line $grid_line;
}
.horizontal > &.has_text {
top: auto;
left: 0;
height: 100%;
max-width: 50%;
// height: auto;
width: auto;
bottom: 0;
padding: $grid_line ($grid_line / 2) $grid_line $grid_line;
}
.quadro > &.has_text {
padding: ($grid_line / 2) ($grid_line / 2) $grid_line $grid_line;
top: auto;
height: 50%;
max-width: 50%;
// height: auto;
width: auto;
bottom: 0;
left: 0;
}
}
}
.menu {
position: absolute;
top: -$gap;
right: -$gap;
z-index: 4;
border-radius: $radius;
pointer-events: none;
touch-action: none;
transition: opacity 0.5s;
box-sizing: border-box;
display: flex;
align-items: stretch;
justify-content: center;
padding: $gap;
&:hover {
opacity: 1;
pointer-events: all;
touch-action: auto;
.menu_content {
opacity: 1;
}
}
@media (max-width: $cell * 2 + $grid_line) {
right: 0;
top: 0;
}
}
.menu_button {
pointer-events: all;
touch-action: auto;
position: absolute;
z-index: 4;
width: 32px + $gap * 2;
height: 32px + $gap * 2;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.2;
svg {
fill: white;
width: 30px;
height: 30px;
}
}
.menu_content {
flex: 1;
opacity: 0;
background: $red_gradient;
padding: (32px + $gap * 2) $gap $gap $gap;
border-radius: $radius;
display: flex;
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
transition: opacity 0.5s;
will-change: opacity;
& > * {
margin-top: $gap;
opacity: 0.5;
transition: opacity 0.25s;
&:hover {
opacity: 1;
}
}
svg {
fill: #222222;
width: 30px;
height: 30px;
}
}
.menu_sep {
width: 20px;
height: 2px;
flex: 0 0 4px;
background-color: #222222;
opacity: 0.2;
border-radius: 2px;
}

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,7 +22,8 @@ type IButtonProps = DetailedHTMLProps<
iconOnly?: boolean;
};
export const Button: FC<IButtonProps> = ({
const Button: FC<IButtonProps> = memo(
({
className = '',
size = 'normal',
iconLeft,
@ -39,7 +40,7 @@ export const Button: FC<IButtonProps> = ({
disabled,
iconOnly,
...props
}) =>
}) =>
createElement(
seamless || non_submitting ? 'div' : 'button',
{
@ -62,4 +63,7 @@ export const Button: FC<IButtonProps> = ({
title ? <span>{title}</span> : children || null,
iconRight && <Icon icon={iconRight} size={20} key={2} />,
]
);
)
);
export { Button };

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,17 +25,21 @@ type Props = ReturnType<typeof mapStateToProps> &
file: IFile;
};
const AudioPlayerUnconnected = ({
const AudioPlayerUnconnected = memo(
({
file,
player: { file: current, status },
playerSetFile,
playerSetFileAndPlay,
playerPlay,
playerPause,
playerSeek,
}: Props) => {
}: Props) => {
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState<IPlayerProgress>({ progress: 0, current: 0, total: 0 });
const [progress, setProgress] = useState<IPlayerProgress>({
progress: 0,
current: 0,
total: 0,
});
const onPlay = useCallback(() => {
if (current && current.id === file.id) {
@ -43,8 +47,8 @@ const AudioPlayerUnconnected = ({
return playerPlay();
}
playerSetFile(file);
}, [file, current, status, playerPlay, playerPause, playerSetFile]);
playerSetFileAndPlay(file);
}, [file, current, status, playerPlay, playerPause, playerSetFileAndPlay]);
const onProgress = useCallback(
({ detail }: { detail: IPlayerProgress }) => {
@ -83,17 +87,23 @@ const AudioPlayerUnconnected = ({
return (
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
<div className={styles.playpause}>
{playing && status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
{playing && status === PLAYER_STATES.PLAYING ? (
<Icon icon="pause" />
) : (
<Icon icon="play" />
)}
</div>
<div className={styles.content}>
<div className={styles.title}>{title || 'Unknown'}</div>
<div className={styles.progress} onClick={onSeek}>
<div className={styles.bar} style={{ width: `${progress.progress}%` }} />
</div>
<div className={styles.title}>{title || 'Unknown'}</div>
</div>
</div>
);
};
}
);
export const AudioPlayer = connect(
mapStateToProps,

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]
);
const Comment: FC<IProps> = memo(
({ comment_group, is_empty, is_same, is_loading, className, ...props }) => {
return (
<CommentWrapper
className={className}
is_empty={is_empty}
is_loading={is_loading}
photo={getURL(comment.user.photo)}
user={comment_group.user}
is_same={is_same}
{...props}
>
{comment.text && (
<Group
className={styles.text}
dangerouslySetInnerHTML={{
__html: formatCommentText(
!is_same && comment.user && comment.user.username,
comment.text
),
}}
/>
)}
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
{groupped.image && (
<div className={styles.images}>
{groupped.image.map(file => (
<div key={file.id}>
<img src={getURL(file)} alt={file.name} />
</div>
<div className={styles.wrap}>
{comment_group.comments.map(comment => (
<CommentContent comment={comment} key={comment.id} />
))}
</div>
)}
{groupped.audio && (
<div className={styles.audios}>
{groupped.audio.map(file => (
<AudioPlayer key={file.id} file={file} />
))}
</div>
)}
</CommentWrapper>
);
};
}
);
export { Comment };

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 }) => (
return (
<div className={styles.wrap}>
{comments.map((comment, index) => (
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
{groupped.map(group => (
<Comment key={group.ids.join()} comment_group={group} />
))}
<Filler />
</div>
);
);
});
export { NodeComments };

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,15 +1,25 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import React, { FC, useCallback, useEffect, useRef, useState, memo } from 'react';
import * as styles from './styles.scss';
import { INode } from '~/redux/types';
import { createPortal } from 'react-dom';
import { NodePanelInner } from '~/components/node/NodePanelInner';
import pick from 'ramda/es/pick';
interface IProps {
node: INode;
node: Partial<INode>;
layout: {};
can_edit: boolean;
can_like: boolean;
can_star: boolean;
onEdit: () => void;
onLike: () => void;
onStar: () => void;
}
const NodePanel: FC<IProps> = ({ node, layout }) => {
const NodePanel: FC<IProps> = memo(
({ node, layout, can_edit, can_like, can_star, onEdit, onLike, onStar }) => {
const [stack, setStack] = useState(false);
const ref = useRef(null);
@ -37,12 +47,33 @@ const NodePanel: FC<IProps> = ({ node, layout }) => {
return (
<div className={styles.place} ref={ref}>
{stack ? (
createPortal(<NodePanelInner node={node} stack />, document.body)
createPortal(
<NodePanelInner
node={node}
stack
onEdit={onEdit}
onLike={onLike}
onStar={onStar}
can_edit={can_edit}
can_like={can_like}
can_star={can_star}
/>,
document.body
)
) : (
<NodePanelInner node={node} />
<NodePanelInner
node={node}
onEdit={onEdit}
onLike={onLike}
onStar={onStar}
can_edit={can_edit}
can_like={can_like}
can_star={can_star}
/>
)}
</div>
);
};
}
);
export { NodePanel };

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,13 +1,16 @@
import React, { FC, HTMLAttributes } from 'react';
import { range } from 'ramda';
import React, { FC } from 'react';
import * as styles from './styles.scss';
import { Group } from '~/components/containers/Group';
import { INode } from '~/redux/types';
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
type IProps = HTMLAttributes<HTMLDivElement> & {}
interface IProps {
title: string;
items: Partial<INode>[];
}
const NodeRelated: FC<IProps> = ({
title,
}) => (
const NodeRelated: FC<IProps> = ({ title, items }) => {
return (
<Group className={styles.wrap}>
<div className={styles.title}>
<div className={styles.line} />
@ -15,11 +18,12 @@ const NodeRelated: FC<IProps> = ({
<div className={styles.line} />
</div>
<div className={styles.grid}>
{
range(1, 7).map(el => (<div className={styles.item} key={el} />))
}
{items.map(item => (
<NodeRelatedItem item={item} key={item.id} />
))}
</div>
</Group>
);
);
};
export { NodeRelated };

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,18 +1,33 @@
import React, { FC } from 'react';
import React, { FC, useCallback } from 'react';
import classNames from 'classnames';
import * as styles from './styles.scss';
import { ArcProgress } from '~/components/input/ArcProgress';
import { IFile } from '~/redux/types';
import { Icon } from '~/components/input/Icon';
interface IProps {
id?: string;
id?: IFile['id'];
thumb?: string;
progress?: number;
onDrop?: (file_id: IFile['id']) => void;
is_uploading?: boolean;
}
const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading }) => (
const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading, id, onDrop }) => {
const onDropFile = useCallback(() => {
if (!id || !onDrop) return;
onDrop(id);
}, [id, onDrop]);
return (
<div className={styles.wrap}>
{id && onDrop && (
<div className={styles.drop} onMouseDown={onDropFile}>
<Icon icon="close" />
</div>
)}
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
{is_uploading && (
@ -22,6 +37,7 @@ const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading }) => (
)}
</div>
</div>
);
);
};
export { ImageUpload };

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