mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 12:56:41 +07:00
Merge remote-tracking branch 'origin/master'
# Conflicts: # src/components/node/CommentForm/index.tsx
This commit is contained in:
commit
1e269e08cd
76 changed files with 7203 additions and 18321 deletions
44
src/components/bars/PlayerBar/index.tsx
Normal file
44
src/components/bars/PlayerBar/index.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React, { FC } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
import { PLAYER_STATES } from '~/redux/player/constants';
|
||||
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';
|
||||
|
||||
const mapStateToProps = state => pick(['status'], selectPlayer(state));
|
||||
const mapDispatchToProps = {
|
||||
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||
playerPause: PLAYER_ACTIONS.playerPause,
|
||||
};
|
||||
|
||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||
|
||||
const PlayerBarUnconnected: FC<IProps> = ({ status }) => {
|
||||
if (status === PLAYER_STATES.UNSET) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.place}>
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.status}>
|
||||
<div className={styles.playpause}>
|
||||
<Icon icon="play" />
|
||||
</div>
|
||||
|
||||
<Filler />
|
||||
|
||||
<div className={styles.close}>
|
||||
<Icon icon="close" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PlayerBar = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(PlayerBarUnconnected);
|
62
src/components/bars/PlayerBar/styles.scss
Normal file
62
src/components/bars/PlayerBar/styles.scss
Normal file
|
@ -0,0 +1,62 @@
|
|||
.place {
|
||||
position: relative;
|
||||
height: 54px;
|
||||
flex: 0 1 500px;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
.seeker {
|
||||
transform: translate(0, -64px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
border-radius: 27px;
|
||||
background: $green_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;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
flex-direction: column;
|
||||
transform: translate(0, 0);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 0 0 54px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.playpause,
|
||||
.close {
|
||||
flex: 0 0 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
fill: $content_bg;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
|
@ -1,22 +1,14 @@
|
|||
import React, { FC } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
|
||||
import classNames = require('classnames');
|
||||
import classNames from 'classnames';
|
||||
|
||||
type IProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
seamless?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
const Card: FC<IProps> = ({
|
||||
className,
|
||||
children,
|
||||
seamless,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={classNames(styles.card, className, { seamless })}
|
||||
{...props}
|
||||
>
|
||||
const Card: FC<IProps> = ({ className, children, seamless, ...props }) => (
|
||||
<div className={classNames(styles.card, className, { seamless })} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import React, {
|
||||
FC, HTMLAttributes, ReactChild, ReactChildren
|
||||
} from 'react';
|
||||
import React, { FC, HTMLAttributes } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
|
||||
import classNames = require('classnames');
|
||||
|
@ -8,14 +6,9 @@ import classNames = require('classnames');
|
|||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: any;
|
||||
size: number;
|
||||
}
|
||||
};
|
||||
|
||||
const CellGrid: FC<IProps> = ({
|
||||
children,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
const CellGrid: FC<IProps> = ({ children, size, className, ...props }) => (
|
||||
<div
|
||||
className={classNames(styles.grid, className)}
|
||||
style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${size}px, 1fr))` }}
|
||||
|
|
|
@ -8,6 +8,7 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
|
|||
photo?: string;
|
||||
is_empty?: boolean;
|
||||
is_loading?: boolean;
|
||||
is_same?: boolean;
|
||||
};
|
||||
|
||||
const CommentWrapper: FC<IProps> = ({
|
||||
|
@ -16,15 +17,16 @@ const CommentWrapper: FC<IProps> = ({
|
|||
is_empty,
|
||||
is_loading,
|
||||
className,
|
||||
is_same,
|
||||
...props
|
||||
}) => (
|
||||
<Card
|
||||
className={classNames(styles.wrap, className, { is_empty, is_loading })}
|
||||
className={classNames(styles.wrap, className, { is_empty, is_loading, is_same })}
|
||||
seamless
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.thumb}>
|
||||
{photo && (
|
||||
{!is_same && photo && (
|
||||
<div className={styles.thumb_image} style={{ backgroundImage: `url("${photo}")` }} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,26 +1,35 @@
|
|||
.wrap {
|
||||
background: $comment_bg;
|
||||
min-height: 64px;
|
||||
min-height: $comment_height;
|
||||
display: flex;
|
||||
box-shadow: $comment_shadow;
|
||||
position: relative;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
|
||||
&:global(.is_empty) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:global(.is_same) {
|
||||
margin: 0 !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex: 0 0 64px;
|
||||
background: transparentize(black, 0.9);
|
||||
flex: 0 0 $comment_height;
|
||||
border-radius: $panel_radius 0 0 $panel_radius;
|
||||
background-color: transparentize(black, 0.9);
|
||||
}
|
||||
|
||||
.thumb_image {
|
||||
height: 64px;
|
||||
background: transparentize(white, 0.97);
|
||||
height: $comment_height;
|
||||
background: transparentize(white, 0.97) no-repeat 50% 50%;
|
||||
border-radius: $panel_radius 0 0 $panel_radius;
|
||||
background-size: cover;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import React, {
|
||||
FC, useCallback, ChangeEventHandler, DragEventHandler,
|
||||
} from 'react';
|
||||
import React, { FC, useCallback, ChangeEventHandler, DragEventHandler } from 'react';
|
||||
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
|
||||
import * as styles from './styles.scss';
|
||||
import { ImageUpload } from '~/components/upload/ImageUpload';
|
||||
|
@ -31,7 +29,7 @@ const SortableList = SortableContainer(
|
|||
<div className={styles.grid}>
|
||||
{items.map((file, index) => (
|
||||
<SortableItem key={file.id} index={index} collection={0}>
|
||||
<ImageUpload id={file.id} thumb={getURL(file.url)} />
|
||||
<ImageUpload id={file.id} thumb={getURL(file)} />
|
||||
</SortableItem>
|
||||
))}
|
||||
|
||||
|
@ -44,9 +42,7 @@ const SortableList = SortableContainer(
|
|||
)
|
||||
);
|
||||
|
||||
const ImageGrid: FC<IProps> = ({
|
||||
items, locked, onFileMove, onUpload,
|
||||
}) => {
|
||||
const ImageGrid: FC<IProps> = ({ items, locked, onFileMove, onUpload }) => {
|
||||
const onMove = useCallback(({ oldIndex, newIndex }) => onFileMove(oldIndex, newIndex), [
|
||||
onFileMove,
|
||||
]);
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import React, { FC, useState, useCallback } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { INode } from '~/redux/types';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { getImageSize } from '~/utils/dom';
|
||||
import { getImageSize, getURL } from '~/utils/dom';
|
||||
import classNames = require('classnames');
|
||||
|
||||
import * as styles from './styles.scss';
|
||||
|
@ -38,11 +37,11 @@ const Cell: FC<IProps> = ({ node: { id, title, brief, type }, onSelect, is_text
|
|||
<div
|
||||
className={styles.thumbnail}
|
||||
style={{
|
||||
backgroundImage: `url("${getImageSize(brief.thumbnail, 'medium')}")`,
|
||||
backgroundImage: `url("${getURL({ url: brief.thumbnail })}")`,
|
||||
opacity: is_loaded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<img src={getImageSize(brief.thumbnail, 'medium')} onLoad={onImageLoad} alt="" />
|
||||
<img src={getURL({ url: brief.thumbnail })} onLoad={onImageLoad} alt="" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -50,12 +49,3 @@ const Cell: FC<IProps> = ({ node: { id, title, brief, type }, onSelect, is_text
|
|||
};
|
||||
|
||||
export { Cell };
|
||||
|
||||
/*
|
||||
{is_text && (
|
||||
<div className={styles.text}>
|
||||
<div className={styles.text_title}>{node.title}</div>
|
||||
{TEXTS.LOREM_IPSUM}
|
||||
</div>
|
||||
)}
|
||||
*/
|
||||
|
|
|
@ -7,9 +7,9 @@ $cols: $content_width / $cell;
|
|||
|
||||
.grid_test {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-rows: 360px;
|
||||
grid-auto-rows: 256px;
|
||||
grid-template-columns: repeat(auto-fit, minmax($cell, 1fr));
|
||||
grid-template-rows: $cell;
|
||||
grid-auto-rows: $cell;
|
||||
grid-auto-flow: row dense;
|
||||
grid-column-gap: $grid_line;
|
||||
grid-row-gap: $grid_line;
|
||||
|
@ -22,8 +22,8 @@ $cols: $content_width / $cell;
|
|||
.hero {
|
||||
grid-row-start: 1;
|
||||
grid-row-end: span 1;
|
||||
grid-column-start: 0;
|
||||
grid-column-end: span 4;
|
||||
grid-column-start: 1;
|
||||
grid-column-end: -1;
|
||||
// gridRow: "1 / 2",
|
||||
// gridColumn: "1 / -1",
|
||||
background: darken($content_bg, 2%);
|
||||
|
|
|
@ -8,7 +8,7 @@ type IButtonProps = DetailedHTMLProps<
|
|||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> & {
|
||||
size?: 'mini' | 'normal' | 'big' | 'giant' | 'micro';
|
||||
size?: 'mini' | 'normal' | 'big' | 'giant' | 'micro' | 'small';
|
||||
iconLeft?: IIcon;
|
||||
iconRight?: IIcon;
|
||||
seamless?: boolean;
|
||||
|
@ -19,6 +19,7 @@ type IButtonProps = DetailedHTMLProps<
|
|||
non_submitting?: boolean;
|
||||
is_loading?: boolean;
|
||||
stretchy?: boolean;
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export const Button: FC<IButtonProps> = ({
|
||||
|
@ -36,6 +37,7 @@ export const Button: FC<IButtonProps> = ({
|
|||
title,
|
||||
stretchy,
|
||||
disabled,
|
||||
iconOnly,
|
||||
...props
|
||||
}) =>
|
||||
createElement(
|
||||
|
@ -49,7 +51,7 @@ export const Button: FC<IButtonProps> = ({
|
|||
disabled,
|
||||
is_loading,
|
||||
stretchy,
|
||||
icon: (iconLeft || iconRight) && !title && !children,
|
||||
icon: ((iconLeft || iconRight) && !title && !children) || iconOnly,
|
||||
has_icon_left: !!iconLeft,
|
||||
has_icon_right: !!iconRight,
|
||||
}),
|
||||
|
@ -57,7 +59,7 @@ export const Button: FC<IButtonProps> = ({
|
|||
},
|
||||
[
|
||||
iconLeft && <Icon icon={iconLeft} size={20} key={0} />,
|
||||
title ? <span key={1}>{title}</span> : (children && <span key={1}>{children}</span>) || null,
|
||||
title ? <span>{title}</span> : children || null,
|
||||
iconRight && <Icon icon={iconRight} size={20} key={2} />,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -27,10 +27,12 @@
|
|||
stroke: white;
|
||||
user-select: none;
|
||||
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: relative;
|
||||
|
||||
filter: grayscale(0);
|
||||
|
||||
transition: opacity 0.25s, filter 0.25s, box-shadow 0.25s;
|
||||
|
@ -38,6 +40,22 @@
|
|||
|
||||
@include outer_shadow();
|
||||
|
||||
input {
|
||||
color: red;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: white;
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
@ -89,14 +107,22 @@
|
|||
|
||||
&:global(.disabled),
|
||||
&:global(.grey) {
|
||||
opacity: 0.3;
|
||||
background: lighten(black, 2%);
|
||||
background: transparentize(white, 0.9);
|
||||
// background: lighten(white, 0.5);
|
||||
// filter: grayscale(100%);
|
||||
}
|
||||
|
||||
&:global(.disabled) {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:global(.icon) {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
|
||||
svg {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.is_loading) {
|
||||
|
@ -138,6 +164,15 @@
|
|||
height: 28px;
|
||||
border-radius: $radius / 2;
|
||||
}
|
||||
.small {
|
||||
height: 32px;
|
||||
// border-radius: $radius / 2;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
.normal {
|
||||
height: 38px;
|
||||
}
|
||||
|
|
6
src/components/input/ButtonGroup/index.tsx
Normal file
6
src/components/input/ButtonGroup/index.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import React, { HTMLAttributes } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {};
|
||||
|
||||
export const ButtonGroup = ({ children }: IProps) => <div className={styles.wrap}>{children}</div>;
|
21
src/components/input/ButtonGroup/styles.scss
Normal file
21
src/components/input/ButtonGroup/styles.scss
Normal file
|
@ -0,0 +1,21 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
& > * {
|
||||
border-radius: 0;
|
||||
box-shadow: transparentize($color: #000000, $amount: 0.1) 1px 0;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 $input_radius $input_radius 0;
|
||||
|
||||
&.small {
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: $input_radius 0 0 $input_radius;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,8 +10,15 @@ import { selectUser } from '~/redux/auth/selectors';
|
|||
import { Group } from '~/components/containers/Group';
|
||||
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 = selectUser;
|
||||
const mapStateToProps = state => ({
|
||||
user: pick(['username', 'is_user', 'photo'])(selectUser(state)),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
push: historyPush,
|
||||
|
@ -20,7 +27,7 @@ const mapDispatchToProps = {
|
|||
|
||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||
|
||||
const HeaderUnconnected: FC<IProps> = ({ username, is_user, showDialog }) => {
|
||||
const HeaderUnconnected: FC<IProps> = ({ user: { username, is_user, photo }, showDialog }) => {
|
||||
const onLogin = useCallback(() => showDialog(DIALOGS.LOGIN), [showDialog]);
|
||||
const onOpenEditor = useCallback(() => showDialog(DIALOGS.EDITOR), [showDialog]);
|
||||
|
||||
|
@ -31,17 +38,16 @@ const HeaderUnconnected: FC<IProps> = ({ username, is_user, showDialog }) => {
|
|||
<Filler />
|
||||
|
||||
<div className={style.plugs}>
|
||||
<Link to="/">flow</Link>
|
||||
<Link to="/examples/image">image</Link>
|
||||
<div onClick={onOpenEditor}>editor</div>
|
||||
<Link to="/">flow</Link>
|
||||
</div>
|
||||
|
||||
<Filler />
|
||||
|
||||
{is_user && (
|
||||
<Group horizontal className={style.user_button}>
|
||||
<div>{username}</div>
|
||||
<div className={style.user_avatar} />
|
||||
<div className={style.user_avatar} style={{ backgroundImage: `url('${getURL(photo)}')` }}>
|
||||
{(!photo || !photo.id) && <Icon icon="profile" />}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
font-weight: 500;
|
||||
height: 80px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
@ -47,11 +47,11 @@
|
|||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
// padding-right: 0;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
// &::after {
|
||||
// display: none;
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -65,16 +65,31 @@
|
|||
.user_button {
|
||||
align-items: center;
|
||||
border-radius: $input_radius;
|
||||
font: $font_16_medium;
|
||||
font: $font_16_semibold;
|
||||
text-transform: uppercase;
|
||||
flex: 0 !important;
|
||||
opacity: 0.3;
|
||||
cursor: pointer;
|
||||
margin-left: $gap;
|
||||
}
|
||||
|
||||
.user_avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@include outer_shadow();
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: white;
|
||||
border-radius: $radius;
|
||||
margin-left: ($gap + 2px) !important;
|
||||
background: 50% 50% no-repeat $wisegreen;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-size: cover;
|
||||
|
||||
svg {
|
||||
fill: #222222;
|
||||
stroke: #222222;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
|
101
src/components/media/AudioPlayer/index.tsx
Normal file
101
src/components/media/AudioPlayer/index.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { selectPlayer } from '~/redux/player/selectors';
|
||||
import * as PLAYER_ACTIONS from '~/redux/player/actions';
|
||||
import { IFile } from '~/redux/types';
|
||||
import { PLAYER_STATES } from '~/redux/player/constants';
|
||||
import { Player, IPlayerProgress } from '~/utils/player';
|
||||
import classNames from 'classnames';
|
||||
import * as styles from './styles.scss';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
player: selectPlayer(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
playerSetFile: PLAYER_ACTIONS.playerSetFile,
|
||||
playerPlay: PLAYER_ACTIONS.playerPlay,
|
||||
playerPause: PLAYER_ACTIONS.playerPause,
|
||||
playerSeek: PLAYER_ACTIONS.playerSeek,
|
||||
};
|
||||
|
||||
type Props = ReturnType<typeof mapStateToProps> &
|
||||
typeof mapDispatchToProps & {
|
||||
file: IFile;
|
||||
};
|
||||
|
||||
const AudioPlayerUnconnected = ({
|
||||
file,
|
||||
player: { file: current, status },
|
||||
|
||||
playerSetFile,
|
||||
playerPlay,
|
||||
playerPause,
|
||||
playerSeek,
|
||||
}: Props) => {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState<IPlayerProgress>({ progress: 0, current: 0, total: 0 });
|
||||
|
||||
const onPlay = useCallback(() => {
|
||||
if (current && current.id === file.id) {
|
||||
if (status === PLAYER_STATES.PLAYING) return playerPause();
|
||||
return playerPlay();
|
||||
}
|
||||
|
||||
playerSetFile(file);
|
||||
}, [file, current, status, playerPlay, playerPause, playerSetFile]);
|
||||
|
||||
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(() => {
|
||||
const active = current && current.id === file.id;
|
||||
setPlaying(current && current.id === file.id);
|
||||
|
||||
if (active) Player.on('playprogress', onProgress);
|
||||
|
||||
return () => {
|
||||
if (active) Player.off('playprogress', onProgress);
|
||||
};
|
||||
}, [file, current, setPlaying, onProgress]);
|
||||
|
||||
const title =
|
||||
file.metadata &&
|
||||
(file.metadata.title ||
|
||||
[file.metadata.id3artist, file.metadata.id3title].filter(el => !!el).join(' - '));
|
||||
|
||||
return (
|
||||
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
|
||||
<div className={styles.playpause}>
|
||||
{playing && status === PLAYER_STATES.PLAYING ? <Icon icon="pause" /> : <Icon icon="play" />}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.progress} onClick={onSeek}>
|
||||
<div className={styles.bar} style={{ width: `${progress.progress}%` }} />
|
||||
</div>
|
||||
<div className={styles.title}>{title || 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AudioPlayer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(AudioPlayerUnconnected);
|
104
src/components/media/AudioPlayer/styles.scss
Normal file
104
src/components/media/AudioPlayer/styles.scss
Normal file
|
@ -0,0 +1,104 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&:global(.playing) {
|
||||
.progress {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
top: 20px;
|
||||
opacity: 1;
|
||||
font-size: 12px;
|
||||
padding-right: 140px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playpause {
|
||||
flex: 0 0 $comment_height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
fill: transparentize($color: white, $amount: 0.5);
|
||||
stroke: transparentize($color: white, $amount: 0.5);
|
||||
transition: fill 250ms, stroke 250ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
fill: white;
|
||||
stroke: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0 $gap * 2 0 $gap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
text-align: left;
|
||||
transition: all 0.5s;
|
||||
font: $font_16_medium;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 20px;
|
||||
position: relative;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
transition: opacity 0.5s;
|
||||
left: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: transparentize(white, 0.95);
|
||||
width: 100%;
|
||||
top: 5px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
background: linear-gradient(270deg, $green, $wisegreen);
|
||||
position: absolute;
|
||||
height: 10px;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
border-radius: 5px;
|
||||
min-width: 10px;
|
||||
transition: width 0.5s;
|
||||
}
|
|
@ -1,28 +1,75 @@
|
|||
import React, { FC, HTMLAttributes } from 'react';
|
||||
import React, { FC, HTMLAttributes, useMemo } from 'react';
|
||||
import { CommentWrapper } from '~/components/containers/CommentWrapper';
|
||||
import { IComment } from '~/redux/types';
|
||||
import { IComment, IFile } from '~/redux/types';
|
||||
import * as styles from './styles.scss';
|
||||
import { formatCommentText } from '~/utils/dom';
|
||||
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;
|
||||
photo?: string;
|
||||
comment?: IComment;
|
||||
is_same?: boolean;
|
||||
};
|
||||
|
||||
const Comment: FC<IProps> = ({ comment, is_empty, is_loading, className, photo, ...props }) => (
|
||||
<CommentWrapper is_empty={is_empty} is_loading={is_loading} photo={photo} {...props}>
|
||||
{comment.text && (
|
||||
<Group
|
||||
className={styles.text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatCommentText(comment.user && comment.user.username, comment.text),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CommentWrapper>
|
||||
);
|
||||
const Comment: FC<IProps> = ({ comment, is_empty, is_same, is_loading, className, ...props }) => {
|
||||
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||
() =>
|
||||
reduce(
|
||||
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
||||
{},
|
||||
comment.files
|
||||
),
|
||||
[comment]
|
||||
);
|
||||
|
||||
return (
|
||||
<CommentWrapper
|
||||
className={className}
|
||||
is_empty={is_empty}
|
||||
is_loading={is_loading}
|
||||
photo={getURL(comment.user.photo)}
|
||||
is_same={is_same}
|
||||
{...props}
|
||||
>
|
||||
{comment.text && (
|
||||
<Group
|
||||
className={styles.text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatCommentText(
|
||||
!is_same && comment.user && comment.user.username,
|
||||
comment.text
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
|
||||
{groupped.image && (
|
||||
<div className={styles.images}>
|
||||
{groupped.image.map(file => (
|
||||
<div key={file.id}>
|
||||
<img src={getURL(file)} alt={file.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupped.audio && (
|
||||
<div className={styles.audios}>
|
||||
{groupped.audio.map(file => (
|
||||
<AudioPlayer key={file.id} file={file} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CommentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export { Comment };
|
||||
|
|
|
@ -1,8 +1,48 @@
|
|||
@import 'flexbin/flexbin.scss';
|
||||
|
||||
.text {
|
||||
padding: $gap / 2;
|
||||
// @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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import * as styles from './styles.scss';
|
|||
import { Filler } from '~/components/containers/Filler';
|
||||
import { Button } from '~/components/input/Button';
|
||||
import assocPath from 'ramda/es/assocPath';
|
||||
import { InputHandler, IFileWithUUID, IFile, IComment } from '~/redux/types';
|
||||
import { InputHandler, IFileWithUUID } from '~/redux/types';
|
||||
import { connect } from 'react-redux';
|
||||
import * as NODE_ACTIONS from '~/redux/node/actions';
|
||||
import { selectNode } from '~/redux/node/selectors';
|
||||
|
@ -16,12 +16,15 @@ import uuid from 'uuid4';
|
|||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||
import { selectUploads } from '~/redux/uploads/selectors';
|
||||
import { IState } from '~/redux/store';
|
||||
import pipe from 'ramda/es/pipe';
|
||||
import { ImageUpload } from '~/components/upload/ImageUpload';
|
||||
import { getImageSize } from '~/utils/dom';
|
||||
import { getFileType } from '~/utils/uploader';
|
||||
import { selectUser } from '~/redux/auth/selectors';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { ButtonGroup } from '~/components/input/ButtonGroup';
|
||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||
|
||||
const mapStateToProps = (state: IState) => ({
|
||||
node: selectNode(state),
|
||||
user: selectUser(state),
|
||||
uploads: selectUploads(state),
|
||||
});
|
||||
|
||||
|
@ -39,12 +42,12 @@ type IProps = ReturnType<typeof mapStateToProps> &
|
|||
const CommentFormUnconnected: FC<IProps> = ({
|
||||
node: { comment_data, is_sending_comment },
|
||||
uploads: { statuses, files },
|
||||
user: { photo },
|
||||
id,
|
||||
nodePostComment,
|
||||
nodeSetCommentData,
|
||||
uploadUploadFiles,
|
||||
}) => {
|
||||
// const [data, setData] = useState<IComment>({ ...EMPTY_COMMENT });
|
||||
const onInputChange = useCallback(
|
||||
event => {
|
||||
event.preventDefault();
|
||||
|
@ -57,7 +60,7 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
temp_id: uuid(),
|
||||
subject: UPLOAD_SUBJECTS.COMMENT,
|
||||
target: UPLOAD_TARGETS.COMMENTS,
|
||||
type: UPLOAD_TYPES.IMAGE,
|
||||
type: getFileType(file),
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -94,68 +97,79 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
[onSubmit]
|
||||
);
|
||||
|
||||
const onFileAdd = useCallback(
|
||||
(file: IFile, temp_id: string) => {
|
||||
const comment = comment_data[id];
|
||||
nodeSetCommentData(id, pipe(
|
||||
assocPath(['files'], [...comment.files, file]),
|
||||
assocPath(['temp_ids'], comment.temp_ids.filter(el => el !== temp_id))
|
||||
)(comment) as IComment);
|
||||
},
|
||||
[nodeSetCommentData, comment_data, id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Object.entries(statuses).forEach(([file_id, status]) => {
|
||||
const comment = comment_data[id];
|
||||
const temp_ids = (comment_data && comment_data[id] && comment_data[id].temp_ids) || [];
|
||||
const added_files = temp_ids
|
||||
.map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid)
|
||||
.map(el => !!el && files[el])
|
||||
.filter(el => !!el && !comment_data[id].files.some(file => file.id === el.id));
|
||||
|
||||
if (comment.temp_ids.includes(file_id) && !!status.uuid && files[status.uuid]) {
|
||||
onFileAdd(files[status.uuid], file_id);
|
||||
}
|
||||
});
|
||||
}, [statuses, comment_data, id, nodeSetCommentData, onFileAdd, files]);
|
||||
const filtered_temps = temp_ids.filter(
|
||||
temp_id =>
|
||||
statuses[temp_id] &&
|
||||
(!statuses[temp_id].uuid || !added_files.some(file => file.id === statuses[temp_id].uuid))
|
||||
);
|
||||
|
||||
if (added_files.length) {
|
||||
nodeSetCommentData(id, {
|
||||
...comment_data[id],
|
||||
temp_ids: filtered_temps,
|
||||
files: [...comment_data[id].files, ...added_files],
|
||||
});
|
||||
}
|
||||
}, [statuses, files]);
|
||||
|
||||
const comment = comment_data[id];
|
||||
|
||||
return (
|
||||
<CommentWrapper>
|
||||
<form onSubmit={onSubmit}>
|
||||
<CommentWrapper photo={getURL(photo)}>
|
||||
<form onSubmit={onSubmit} className={styles.wrap}>
|
||||
<div className={styles.input}>
|
||||
<Textarea
|
||||
value={comment_data[id].text}
|
||||
value={comment.text}
|
||||
handler={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={is_sending_comment}
|
||||
minRows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.uploads}>
|
||||
{comment_data[id].files.map(file => (
|
||||
<ImageUpload id={file.id} thumb={getImageSize(file.url)} key={file.id} />
|
||||
))}
|
||||
{comment_data[id].temp_ids.map(
|
||||
status =>
|
||||
statuses[status] && (
|
||||
<ImageUpload
|
||||
id={statuses[status].uuid}
|
||||
thumb={statuses[status].preview}
|
||||
key={status}
|
||||
progress={statuses[status].progress}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Group horizontal className={styles.buttons}>
|
||||
<input type="file" onInput={onInputChange} />
|
||||
<ButtonGroup>
|
||||
<Button iconLeft="image" size="small" grey iconOnly>
|
||||
<input type="file" onInput={onInputChange} multiple accept="image/*" />
|
||||
</Button>
|
||||
|
||||
<Button iconRight="enter" size="small" grey iconOnly>
|
||||
<input type="file" onInput={onInputChange} multiple accept="audio/*" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Filler />
|
||||
|
||||
{is_sending_comment && <LoaderCircle size={20} />}
|
||||
|
||||
<Button size="mini" grey iconRight="enter" disabled={is_sending_comment}>
|
||||
<Button size="small" grey iconRight="enter" disabled={is_sending_comment}>
|
||||
Сказать
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
textarea {
|
||||
min-height: 62px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: $gap / 2;
|
||||
padding: ($gap / 2) ($gap / 2 + 1px);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
|
@ -14,12 +18,7 @@
|
|||
background: transparentize(black, 0.8);
|
||||
padding: $gap / 2;
|
||||
border-radius: 0 0 $radius $radius;
|
||||
|
||||
svg {
|
||||
fill: transparentize(white, 0.9);
|
||||
}
|
||||
|
||||
@include outer_shadow();
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.uploads {
|
||||
|
|
|
@ -11,18 +11,22 @@ interface IProps {
|
|||
onChange: (current: number) => void;
|
||||
}
|
||||
|
||||
const ImageSwitcher: FC<IProps> = ({ total, current, onChange, loaded }) => (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.switcher}>
|
||||
{range(0, total).map(item => (
|
||||
<div
|
||||
className={classNames({ is_active: item === current, is_loaded: loaded[item] })}
|
||||
key={item}
|
||||
onClick={() => onChange(item)}
|
||||
/>
|
||||
))}
|
||||
const ImageSwitcher: FC<IProps> = ({ total, current, onChange, loaded }) => {
|
||||
if (total <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.switcher}>
|
||||
{range(0, total).map(item => (
|
||||
<div
|
||||
className={classNames({ is_active: item === current, is_loaded: loaded[item] })}
|
||||
key={item}
|
||||
onClick={() => onChange(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export { ImageSwitcher };
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import React, { FC } from 'react';
|
||||
import range from 'ramda/es/range';
|
||||
import { Comment } from '../Comment';
|
||||
import { INode } from '~/redux/types';
|
||||
import { CommentForm } from '../CommentForm';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import * as styles from './styles.scss';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
|
||||
import * as styles from './styles.scss';
|
||||
|
||||
interface IProps {
|
||||
comments?: any;
|
||||
}
|
||||
|
||||
const isSameComment = (comments, index) =>
|
||||
comments[index - 1] && comments[index - 1].user.id === comments[index].user.id;
|
||||
|
||||
const NodeComments: FC<IProps> = ({ comments }) => (
|
||||
<Group className={styles.wrap}>
|
||||
{comments.map(comment => (
|
||||
<Comment key={comment.id} comment={comment} />
|
||||
<div className={styles.wrap}>
|
||||
{comments.map((comment, index) => (
|
||||
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
|
||||
))}
|
||||
|
||||
<Filler />
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { NodeComments };
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
.wrap {
|
||||
& > div {
|
||||
margin: $gap 0 0 0;
|
||||
}
|
||||
|
||||
// display: flex;
|
||||
// flex-direction: column !important;
|
||||
|
||||
|
|
|
@ -18,9 +18,11 @@ import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
|||
interface IProps {
|
||||
is_loading: boolean;
|
||||
node: INode;
|
||||
layout: {};
|
||||
updateLayout: () => void;
|
||||
}
|
||||
|
||||
const NodeImageBlock: FC<IProps> = ({ node, is_loading }) => {
|
||||
const NodeImageBlock: FC<IProps> = ({ node, is_loading, updateLayout }) => {
|
||||
const [is_animated, setIsAnimated] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [height, setHeight] = useState(320);
|
||||
|
@ -39,6 +41,8 @@ const NodeImageBlock: FC<IProps> = ({ node, is_loading }) => {
|
|||
loaded,
|
||||
]);
|
||||
|
||||
useEffect(() => updateLayout(), [loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refs || !refs.current[current] || !loaded[current]) return setHeight(320);
|
||||
|
||||
|
@ -78,7 +82,7 @@ const NodeImageBlock: FC<IProps> = ({ node, is_loading }) => {
|
|||
>
|
||||
<img
|
||||
className={styles.image}
|
||||
src={getImageSize(file.url, 'node')}
|
||||
src={getImageSize(file, 'node')}
|
||||
alt=""
|
||||
key={file.id}
|
||||
onLoad={onImageLoad(index)}
|
||||
|
|
|
@ -31,6 +31,8 @@
|
|||
text-transform: uppercase;
|
||||
font: $font_18_semibold;
|
||||
color: transparentize(white, 0.5);
|
||||
flex: 0 0 $comment_height;
|
||||
|
||||
@include outer_shadow();
|
||||
|
||||
&:nth-child(2) {
|
||||
|
|
|
@ -1,30 +1,48 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
import {Icon} from "~/components/input/Icon";
|
||||
import { INode } from '~/redux/types';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NodePanelInner } from '~/components/node/NodePanelInner';
|
||||
|
||||
interface IProps {}
|
||||
interface IProps {
|
||||
node: INode;
|
||||
layout: {};
|
||||
}
|
||||
|
||||
const NodePanel: FC<IProps> = () => (
|
||||
<div className={styles.wrap}>
|
||||
<Group horizontal className={styles.panel}>
|
||||
<Filler>
|
||||
<div className={styles.title}>Node title</div>
|
||||
<div className={styles.name}>~author</div>
|
||||
</Filler>
|
||||
</Group>
|
||||
const NodePanel: FC<IProps> = ({ node, layout }) => {
|
||||
const [stack, setStack] = useState(false);
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<Icon icon="edit" size={24} />
|
||||
const ref = useRef(null);
|
||||
const getPlace = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
<div className={styles.sep} />
|
||||
const { offsetTop } = ref.current;
|
||||
const { height } = ref.current.getBoundingClientRect();
|
||||
const { scrollY, innerHeight } = window;
|
||||
|
||||
<Icon icon="heart" size={24} />
|
||||
setStack(offsetTop > scrollY + innerHeight - height);
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
getPlace();
|
||||
window.addEventListener('scroll', getPlace);
|
||||
window.addEventListener('resize', getPlace);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', getPlace);
|
||||
window.removeEventListener('resize', getPlace);
|
||||
};
|
||||
}, [layout]);
|
||||
|
||||
return (
|
||||
<div className={styles.place} ref={ref}>
|
||||
{stack ? (
|
||||
createPortal(<NodePanelInner node={node} stack />, document.body)
|
||||
) : (
|
||||
<NodePanelInner node={node} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export { NodePanel };
|
||||
|
||||
// <div className={styles.mark} />
|
||||
|
|
|
@ -1,91 +1,6 @@
|
|||
.wrap {
|
||||
background: $node_bg;
|
||||
padding: $gap;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
border-radius: $radius $radius 0 0;
|
||||
.place {
|
||||
height: 72px;
|
||||
position: relative;
|
||||
margin-top: -$radius;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
font: $font_24_semibold;
|
||||
height: 24px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font: $font_12_regular;
|
||||
color: transparentize(white, 0.5);
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
fill: transparentize(white, 0.5);
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex: 0;
|
||||
padding-right: $gap;
|
||||
fill: transparentize(white, 0.7);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
& > * {
|
||||
margin: 0 $gap;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
//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 {
|
||||
flex: 0 0 32px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: -38px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 52px;
|
||||
background: $green_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);
|
||||
margin-top: -$radius;
|
||||
}
|
||||
|
|
39
src/components/node/NodePanelInner/index.tsx
Normal file
39
src/components/node/NodePanelInner/index.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { INode } from '~/redux/types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
stack?: boolean;
|
||||
}
|
||||
|
||||
const NodePanelInner: FC<IProps> = ({ node: { title, user }, stack }) => {
|
||||
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>}
|
||||
</Filler>
|
||||
</Group>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<Icon icon="edit" size={24} />
|
||||
|
||||
<div className={styles.sep} />
|
||||
|
||||
<Icon icon="heart" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodePanelInner };
|
||||
|
||||
// <div className={styles.mark} />
|
109
src/components/node/NodePanelInner/styles.scss
Normal file
109
src/components/node/NodePanelInner/styles.scss
Normal file
|
@ -0,0 +1,109 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
position: relative;
|
||||
height: 72px;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:global(.stack) {
|
||||
padding: 0 $gap;
|
||||
bottom: 0;
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 0 1 $content_width;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
border-radius: $radius $radius 0 0;
|
||||
box-sizing: border-box;
|
||||
padding: $gap;
|
||||
background: $node_bg;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
font: $font_24_semibold;
|
||||
height: 24px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font: $font_14_regular;
|
||||
color: transparentize(white, 0.5);
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
fill: transparentize(white, 0.5);
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex: 0;
|
||||
padding-right: $gap;
|
||||
fill: transparentize(white, 0.7);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
& > * {
|
||||
margin: 0 $gap;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
//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 {
|
||||
flex: 0 0 32px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: -38px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 52px;
|
||||
background: $green_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);
|
||||
}
|
|
@ -1,18 +1,15 @@
|
|||
import React, { FC } from 'react';
|
||||
import { Tags } from '../Tags';
|
||||
import { ITag } from '~/redux/types';
|
||||
|
||||
interface IProps {}
|
||||
interface IProps {
|
||||
is_editable?: boolean;
|
||||
tags: ITag[];
|
||||
onChange?: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
const NodeTags: FC<IProps> = ({}) => (
|
||||
<Tags
|
||||
tags={[
|
||||
{ title: 'Избранный', feature: 'red' },
|
||||
{ title: 'Плейлист', feature: 'green' },
|
||||
{ title: 'Просто' },
|
||||
{ title: '+ фото', feature: 'black' },
|
||||
{ title: '+ с музыкой', feature: 'black' },
|
||||
]}
|
||||
/>
|
||||
const NodeTags: FC<IProps> = ({ is_editable, tags, onChange }) => (
|
||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
||||
);
|
||||
|
||||
export { NodeTags };
|
||||
|
|
|
@ -1,25 +1,41 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, ChangeEventHandler, KeyboardEventHandler, FocusEventHandler } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { ITag } from '~/redux/types';
|
||||
|
||||
import classNames = require('classnames');
|
||||
|
||||
const getTagFeature = (tag: Partial<ITag>) => {
|
||||
if (tag.title.substr(0, 1) === '/') return 'green';
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
title: ITag['title'];
|
||||
feature?: ITag['feature'];
|
||||
tag: Partial<ITag>;
|
||||
|
||||
is_hoverable?: boolean;
|
||||
onInput?: ChangeEventHandler<HTMLInputElement>;
|
||||
onKeyUp?: KeyboardEventHandler;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const Tag: FC<IProps> = ({
|
||||
title,
|
||||
feature,
|
||||
|
||||
is_hoverable,
|
||||
}) => (
|
||||
<div className={classNames(styles.tag, feature, { is_hoverable })}>
|
||||
const Tag: FC<IProps> = ({ tag, is_hoverable, onInput, onKeyUp, onBlur }) => (
|
||||
<div className={classNames(styles.tag, getTagFeature(tag), { is_hoverable, input: !!onInput })}>
|
||||
<div className={styles.hole} />
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.title}>{tag.title}</div>
|
||||
|
||||
{onInput && (
|
||||
<input
|
||||
type="text"
|
||||
value={tag.title}
|
||||
size={1}
|
||||
placeholder="Добавить"
|
||||
maxLength={24}
|
||||
onChange={onInput}
|
||||
onKeyUp={onKeyUp}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -6,11 +6,12 @@
|
|||
align-items: center;
|
||||
justify-content: stretch;
|
||||
border-radius: ($tag_height / 2) 3px 3px ($tag_height / 2);
|
||||
font: $font_12_semibold;
|
||||
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;
|
||||
position: relative;
|
||||
|
||||
&:global(.is_hoverable) {
|
||||
cursor: pointer;
|
||||
|
@ -37,9 +38,35 @@
|
|||
background: transparentize(black, 0.7);
|
||||
box-shadow: none;
|
||||
color: transparentize(white, 0.6);
|
||||
font: $font_12_medium;
|
||||
font: $font_14_medium;
|
||||
|
||||
.hole::after { background: transparentize(white, 0.98); }
|
||||
.hole::after {
|
||||
background: transparentize(white, 0.98);
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.input) {
|
||||
color: transparent !important;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
input {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
outline: none;
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
font: inherit;
|
||||
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding-left: 23px;
|
||||
padding-right: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,6 +77,7 @@
|
|||
margin-right: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 22px;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
|
@ -63,4 +91,7 @@
|
|||
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
|
@ -1,25 +1,91 @@
|
|||
import React, { FC, HTMLAttributes } from 'react';
|
||||
import React, {
|
||||
FC,
|
||||
HTMLAttributes,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
KeyboardEvent,
|
||||
ChangeEvent,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { TagField } from '~/components/containers/TagField';
|
||||
import { ITag } from '~/redux/types';
|
||||
import { Tag } from '~/components/node/Tag';
|
||||
import uniq from 'ramda/es/uniq';
|
||||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||
tags: ITag[];
|
||||
}
|
||||
tags: Partial<ITag>[];
|
||||
is_editable?: boolean;
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
export const Tags: FC<IProps> = ({
|
||||
tags,
|
||||
...props
|
||||
}) => (
|
||||
<TagField {...props}>
|
||||
{
|
||||
tags.map(tag => (
|
||||
<Tag
|
||||
key={tag.title}
|
||||
title={tag.title}
|
||||
feature={tag.feature}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</TagField>
|
||||
);
|
||||
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, ...props }) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [data, setData] = useState([]);
|
||||
const timer = useRef(null);
|
||||
|
||||
const onInput = useCallback(
|
||||
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
|
||||
clearTimeout(timer.current);
|
||||
setInput(value);
|
||||
},
|
||||
[setInput, timer]
|
||||
);
|
||||
|
||||
const onKeyUp = useCallback(
|
||||
({ key }: KeyboardEvent) => {
|
||||
if (key === 'Backspace' && input === '' && data.length) {
|
||||
setData(data.slice(0, data.length - 1));
|
||||
setInput(data[data.length - 1].title);
|
||||
}
|
||||
|
||||
if (key === 'Enter' || key === ',' || key === 'Comma') {
|
||||
setData(
|
||||
uniq([
|
||||
...data,
|
||||
...input
|
||||
.split(',')
|
||||
.map((title: string) =>
|
||||
title
|
||||
.trim()
|
||||
.substr(0, 32)
|
||||
.toLowerCase()
|
||||
)
|
||||
.filter(el => el.length > 0)
|
||||
.filter(el => !tags.some(tag => tag.title.trim() === el.trim()))
|
||||
.map(title => ({
|
||||
title,
|
||||
})),
|
||||
])
|
||||
);
|
||||
setInput('');
|
||||
}
|
||||
},
|
||||
[input, setInput, data, setData]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
if (!data.length) return;
|
||||
onTagsChange(uniq([...tags, ...data]).map(tag => tag.title));
|
||||
}, [tags, data, onTagsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<TagField {...props}>
|
||||
{tags.map(tag => (
|
||||
<Tag key={tag.title} tag={tag} />
|
||||
))}
|
||||
|
||||
{data.map(tag => (
|
||||
<Tag key={tag.title} tag={tag} />
|
||||
))}
|
||||
|
||||
{is_editable && (
|
||||
<Tag tag={{ title: input }} onInput={onInput} onKeyUp={onKeyUp} onBlur={onSubmit} />
|
||||
)}
|
||||
</TagField>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -11,9 +11,7 @@ interface IProps {
|
|||
is_uploading?: boolean;
|
||||
}
|
||||
|
||||
const ImageUpload: FC<IProps> = ({
|
||||
thumb, id, progress, is_uploading,
|
||||
}) => (
|
||||
const ImageUpload: FC<IProps> = ({ thumb, progress, is_uploading }) => (
|
||||
<div className={styles.wrap}>
|
||||
<div className={classNames(styles.thumb_wrap, { is_uploading })}>
|
||||
{thumb && <div className={styles.thumb} style={{ backgroundImage: `url("${thumb}")` }} />}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue