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

View file

@ -1,75 +1,36 @@
import React, { FC, HTMLAttributes, useMemo } from 'react';
import React, { FC, HTMLAttributes, memo } from 'react';
import { CommentWrapper } from '~/components/containers/CommentWrapper';
import { IComment, IFile } from '~/redux/types';
import { ICommentGroup } from '~/redux/types';
import { getURL } from '~/utils/dom';
import { CommentContent } from '~/components/node/CommentContent';
import * as styles from './styles.scss';
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
import { Group } from '~/components/containers/Group';
import assocPath from 'ramda/es/assocPath';
import append from 'ramda/es/append';
import reduce from 'ramda/es/reduce';
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { AudioPlayer } from '~/components/media/AudioPlayer';
type IProps = HTMLAttributes<HTMLDivElement> & {
is_empty?: boolean;
is_loading?: boolean;
comment?: IComment;
comment_group?: ICommentGroup;
is_same?: boolean;
};
const Comment: FC<IProps> = ({ comment, is_empty, is_same, is_loading, className, ...props }) => {
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
() =>
reduce(
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
{},
comment.files
),
[comment]
);
return (
<CommentWrapper
className={className}
is_empty={is_empty}
is_loading={is_loading}
photo={getURL(comment.user.photo)}
is_same={is_same}
{...props}
>
{comment.text && (
<Group
className={styles.text}
dangerouslySetInnerHTML={{
__html: formatCommentText(
!is_same && comment.user && comment.user.username,
comment.text
),
}}
/>
)}
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
{groupped.image && (
<div className={styles.images}>
{groupped.image.map(file => (
<div key={file.id}>
<img src={getURL(file)} alt={file.name} />
</div>
const Comment: FC<IProps> = memo(
({ comment_group, is_empty, is_same, is_loading, className, ...props }) => {
return (
<CommentWrapper
className={className}
is_empty={is_empty}
is_loading={is_loading}
user={comment_group.user}
is_same={is_same}
{...props}
>
<div className={styles.wrap}>
{comment_group.comments.map(comment => (
<CommentContent comment={comment} key={comment.id} />
))}
</div>
)}
{groupped.audio && (
<div className={styles.audios}>
{groupped.audio.map(file => (
<AudioPlayer key={file.id} file={file} />
))}
</div>
)}
</CommentWrapper>
);
};
</CommentWrapper>
);
}
);
export { Comment };

View file

@ -1,48 +1,11 @@
@import 'flexbin/flexbin.scss';
.text {
// @include outer_shadow();
padding: $gap;
font-weight: 300;
font: $font_16_medium;
min-height: $comment_height;
box-sizing: border-box;
position: relative;
color: #cccccc;
b {
font-weight: 600;
@keyframes appear {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.date {
position: absolute;
bottom: 0;
right: 0;
font: $font_12_regular;
color: transparentize($color: white, $amount: 0.8);
padding: 2px 4px;
border-radius: 0 0 $radius 0;
}
.images {
@include flexbin(240px, 5px);
img {
border-radius: $radius;
}
}
.audios {
& > div {
@include outer_shadow();
height: $comment_height;
border-radius: $radius;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.wrap {
animation: appear 1s;
}

View file

@ -0,0 +1,84 @@
import React, { FC, useMemo, memo } from 'react';
import { IComment, IFile } from '~/redux/types';
import path from 'ramda/es/path';
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
import { Group } from '~/components/containers/Group';
import * as styles from './styles.scss';
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import assocPath from 'ramda/es/assocPath';
import append from 'ramda/es/append';
import reduce from 'ramda/es/reduce';
import { AudioPlayer } from '~/components/media/AudioPlayer';
import classnames from 'classnames';
interface IProps {
comment: IComment;
}
const CommentContent: FC<IProps> = memo(({ comment }) => {
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
() =>
reduce(
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
{},
comment.files
),
[comment]
);
return (
<>
{comment.text && (
<div className={styles.block}>
<Group
className={styles.text}
dangerouslySetInnerHTML={{
__html: formatCommentText(path(['user', 'username'], comment), comment.text),
}}
/>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div>
)}
{groupped.image && groupped.image.length > 0 && (
<div className={classnames(styles.block, styles.block_image)}>
<div className={styles.images}>
{groupped.image.map(file => (
<div key={file.id}>
<img src={getURL(file)} alt={file.name} />
</div>
))}
</div>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div>
)}
{groupped.audio && groupped.audio.length > 0 && (
<>
{groupped.audio.map(file => (
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
<AudioPlayer file={file} />
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div>
))}
</>
)}
</>
);
});
export { CommentContent };
/*
{comment.text && (
)}
*/

View file

@ -0,0 +1,80 @@
@import 'flexbin/flexbin.scss';
.block {
@include outer_shadow();
min-height: $comment_height;
// box-shadow: inset rgba(255, 255, 255, 0.05) 1px 1px, inset rgba(0, 0, 0, 0.1) -1px -1px;
display: flex;
align-items: flex-start;
justify-content: flex-start;
position: relative;
padding-bottom: 10px;
box-sizing: border-box;
&:first-child {
border-top-right-radius: $radius;
}
&:last-child {
border-bottom-right-radius: $radius;
}
}
.block_audio {
align-items: center;
justify-content: center;
padding-bottom: 0 !important;
}
.block_image {
padding-bottom: 0 !important;
.date {
background: transparentize($color: $content_bg, $amount: 0.2);
border-radius: $radius 0 $radius 0;
color: transparentize(white, 0.2);
}
}
.text {
padding: $gap;
font-weight: 300;
font: $font_16_medium;
line-height: 20px;
box-sizing: border-box;
position: relative;
color: #cccccc;
b {
font-weight: 600;
}
}
.date {
position: absolute;
bottom: 0;
right: 0;
font: $font_12_regular;
color: transparentize($color: white, $amount: 0.8);
padding: 4px 6px 4px 6px;
border-radius: 0 0 $radius 0;
}
.images {
@include flexbin(240px, 5px);
img {
border-radius: $radius;
}
}
.audios {
& > div {
height: $comment_height;
border-radius: $radius;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
}

View file

@ -42,7 +42,7 @@ type IProps = ReturnType<typeof mapStateToProps> &
const CommentFormUnconnected: FC<IProps> = ({
node: { comment_data, is_sending_comment },
uploads: { statuses, files },
user: { photo },
user,
id,
nodePostComment,
nodeSetCommentData,
@ -122,7 +122,7 @@ const CommentFormUnconnected: FC<IProps> = ({
const comment = comment_data[id];
return (
<CommentWrapper photo={getURL(photo)}>
<CommentWrapper user={user}>
<form onSubmit={onSubmit} className={styles.wrap}>
<div className={styles.input}>
<Textarea
@ -134,6 +134,22 @@ const CommentFormUnconnected: FC<IProps> = ({
/>
</div>
{comment.temp_ids.map(
temp_id =>
statuses[temp_id] &&
statuses[temp_id].is_uploading && (
<div key={statuses[temp_id].temp_id}>{statuses[temp_id].progress}</div>
)
)}
{comment.files.map(file => {
if (file.type === UPLOAD_TYPES.AUDIO) {
return <AudioPlayer file={file} />;
}
return <div>file.name</div>;
})}
<Group horizontal className={styles.buttons}>
<ButtonGroup>
<Button iconLeft="image" size="small" grey iconOnly>
@ -154,22 +170,6 @@ const CommentFormUnconnected: FC<IProps> = ({
</Button>
</Group>
</form>
{comment.temp_ids.map(
temp_id =>
statuses[temp_id] &&
statuses[temp_id].is_uploading && (
<div key={statuses[temp_id].temp_id}>{statuses[temp_id].progress}</div>
)
)}
{comment.files.map(file => {
if (file.type === UPLOAD_TYPES.AUDIO) {
return <AudioPlayer file={file} />;
}
return <div>file.name</div>;
})}
</CommentWrapper>
);
};

View file

@ -0,0 +1,26 @@
import React, { FC, useMemo } from 'react';
import { INode } from '~/redux/types';
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { AudioPlayer } from '~/components/media/AudioPlayer';
import * as styles from './styles.scss';
interface IProps {
node: INode;
}
const NodeAudioBlock: FC<IProps> = ({ node }) => {
const audios = useMemo(
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
[node.files]
);
return (
<div className={styles.wrap}>
{audios.map(file => (
<AudioPlayer key={file.id} file={file} />
))}
</div>
);
};
export { NodeAudioBlock };

View file

@ -0,0 +1,17 @@
.wrap {
background: $content_bg;
border-radius: $radius;
& > div {
@include outer_shadow();
&:first-child {
border-top-left-radius: $radius;
border-top-right-radius: $radius;
}
&:last-child {
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
}
}
}

View file

@ -0,0 +1,28 @@
import React, { FC, useMemo } from 'react';
import { INode } from '~/redux/types';
import * as styles from './styles.scss';
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import path from 'ramda/es/path';
import { getURL } from '~/utils/dom';
interface IProps {
node: INode;
}
const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
const images = useMemo(
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
[node.files]
);
return (
<div className={styles.wrap}>
<div
className={styles.slide}
style={{ backgroundImage: `url("${getURL(path([0], images))}")` }}
/>
</div>
);
};
export { NodeAudioImageBlock };

View file

@ -0,0 +1,33 @@
.wrap {
@include outer_shadow();
padding-bottom: 33vh;
position: relative;
border-radius: $radius $radius 0 0;
&::after {
border-radius: $radius $radius 0 0;
content: ' ';
z-index: 3;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5) url('~/sprites/dots.svg');
}
}
.slide {
@include outer_shadow();
border-radius: $radius $radius 0 0;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: no-repeat 50% 30%;
background-size: cover;
z-index: 1;
will-change: transform;
}

View file

@ -1,24 +1,29 @@
import React, { FC } from 'react';
import React, { FC, useMemo, memo } from 'react';
import { Comment } from '../Comment';
import { Filler } from '~/components/containers/Filler';
import * as styles from './styles.scss';
import { ICommentGroup, IComment } from '~/redux/types';
import { groupCommentsByUser } from '~/utils/fn';
interface IProps {
comments?: any;
comments?: IComment[];
}
const isSameComment = (comments, index) =>
comments[index - 1] && comments[index - 1].user.id === comments[index].user.id;
const NodeComments: FC<IProps> = memo(({ comments }) => {
const groupped: ICommentGroup[] = useMemo(() => comments.reduce(groupCommentsByUser, []), [
comments,
]);
const NodeComments: FC<IProps> = ({ comments }) => (
<div className={styles.wrap}>
{comments.map((comment, index) => (
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
))}
return (
<div className={styles.wrap}>
{groupped.map(group => (
<Comment key={group.ids.join()} comment_group={group} />
))}
<Filler />
</div>
);
<Filler />
</div>
);
});
export { NodeComments };

View file

@ -1,20 +1,9 @@
.wrap {
& > div {
margin: $gap 0 0 0;
margin: 0 0 $gap 0;
&:last-child {
margin: 0;
}
}
// display: flex;
// flex-direction: column !important;
// & > div {
// margin: ($gap / 2) 0;
// &:last-child {
// margin-top: 0;
// }
// &:first-child {
// margin-bottom: 0;
// }
// }
}

View file

@ -0,0 +1,217 @@
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { ImageSwitcher } from '../ImageSwitcher';
import * as styles from './styles.scss';
import { INode } from '~/redux/types';
import classNames from 'classnames';
import { getImageSize } from '~/utils/dom';
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { NODE_SETTINGS } from '~/redux/node/constants';
interface IProps {
is_loading: boolean;
node: INode;
layout: {};
updateLayout: () => void;
}
const getX = event => (event.touches ? event.touches[0].clientX : event.clientX);
const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) => {
const [current, setCurrent] = useState(0);
const [height, setHeight] = useState(320);
const [max_height, setMaxHeight] = useState(960);
const [loaded, setLoaded] = useState<Record<number, boolean>>({});
const refs = useRef<Record<number, HTMLDivElement>>({});
const [heights, setHeights] = useState({});
const [initial_offset, setInitialOffset] = useState(0);
const [initial_x, setInitialX] = useState(0);
const [offset, setOffset] = useState(0);
const [is_dragging, setIsDragging] = useState(false);
const slide = useRef<HTMLDivElement>();
const wrap = useRef<HTMLDivElement>();
const images = useMemo(
() =>
(node && node.files && node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || [],
[node]
);
const updateSizes = useCallback(() => {
const values = Object.keys(refs.current).reduce((obj, key) => {
const ref = refs.current[key];
if (!ref || !ref.getBoundingClientRect) return 0;
return { ...obj, [key]: ref.getBoundingClientRect().height };
}, {});
setHeights(values);
}, [refs]);
const setRef = useCallback(
index => el => {
refs.current[index] = el;
},
[refs, heights, setHeights]
);
const onImageLoad = useCallback(index => () => setLoaded({ ...loaded, [index]: true }), [
setLoaded,
loaded,
]);
// update outside hooks
useEffect(() => updateLayout(), [loaded, height]);
useEffect(() => updateSizes(), [refs, current, loaded]);
useEffect(() => {
if (!wrap || !wrap.current) return;
const { width } = wrap.current.getBoundingClientRect();
const selected = Math.abs(-offset / width);
const prev = Math.max(heights[Math.floor(selected)] || 320, 320);
const next = Math.max(heights[Math.ceil(selected)] || 320, 320);
const now = prev - (prev - next) * (selected % 1);
if (current !== Math.round(selected)) setCurrent(Math.round(selected));
setHeight(now);
}, [offset, heights, max_height]);
const onDrag = useCallback(
event => {
if (
!is_dragging ||
!slide.current ||
!wrap.current ||
(event.touches && event.clientY > event.clientX)
)
return;
const { width: slide_width } = slide.current.getBoundingClientRect();
const { width: wrap_width } = wrap.current.getBoundingClientRect();
setOffset(
Math.min(Math.max(initial_offset + getX(event) - initial_x, wrap_width - slide_width), 0)
);
},
[is_dragging, initial_x, setOffset, initial_offset]
);
const normalizeOffset = useCallback(() => {
const { width: wrap_width } = wrap.current.getBoundingClientRect();
const { width: slide_width } = slide.current.getBoundingClientRect();
const shift = (initial_offset - offset) / wrap_width; // percent / 100
const diff = initial_offset - (shift > 0 ? Math.ceil(shift) : Math.floor(shift)) * wrap_width;
const new_offset =
Math.abs(shift) > 0.25
? Math.min(Math.max(diff, wrap_width - slide_width), 0) // next or prev slide
: Math.round(offset / wrap_width) * wrap_width; // back to this one
setOffset(new_offset);
}, [wrap, offset, initial_offset]);
const updateMaxHeight = useCallback(() => {
if (!wrap.current) return;
const { width } = wrap.current.getBoundingClientRect();
setMaxHeight(width * NODE_SETTINGS.MAX_IMAGE_ASPECT);
normalizeOffset();
}, [wrap, setMaxHeight, normalizeOffset]);
const stopDragging = useCallback(() => {
if (!is_dragging) return;
normalizeOffset();
setIsDragging(false);
}, [setIsDragging, is_dragging, normalizeOffset]);
const startDragging = useCallback(
event => {
setIsDragging(true);
setInitialX(getX(event));
setInitialOffset(offset);
},
[setIsDragging, setInitialX, offset, setInitialOffset]
);
useEffect(() => updateMaxHeight(), []);
useEffect(() => {
window.addEventListener('resize', updateSizes);
window.addEventListener('resize', updateMaxHeight);
window.addEventListener('mousemove', onDrag);
window.addEventListener('touchmove', onDrag);
window.addEventListener('mouseup', stopDragging);
window.addEventListener('touchend', stopDragging);
return () => {
window.removeEventListener('resize', updateSizes);
window.removeEventListener('resize', updateMaxHeight);
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('touchmove', onDrag);
window.removeEventListener('mouseup', stopDragging);
window.removeEventListener('touchend', stopDragging);
};
}, [onDrag, stopDragging, updateMaxHeight, updateSizes]);
const changeCurrent = useCallback(
(item: number) => {
const { width } = wrap.current.getBoundingClientRect();
setOffset(-1 * item * width);
},
[wrap]
);
return (
<div className={classNames(styles.wrap, { is_loading })} ref={wrap}>
<ImageSwitcher
total={images.length}
current={current}
onChange={changeCurrent}
loaded={loaded}
/>
<div
className={styles.image_container}
style={{
transition: is_dragging ? 'none' : 'transform 500ms',
height,
transform: `translate(${offset}px, 0)`,
width: `${images.length * 100}%`,
}}
onMouseDown={startDragging}
onTouchStart={startDragging}
ref={slide}
>
{(is_loading || !loaded[0] || !images.length) && <div className={styles.placeholder} />}
{images.map((file, index) => (
<div
className={classNames(styles.image_wrap, {
is_active: index === current && loaded[index],
})}
ref={setRef(index)}
key={file.id}
>
<img
className={styles.image}
src={getImageSize(file, 'node')}
alt=""
key={file.id}
onLoad={onImageLoad(index)}
style={{ maxHeight: max_height }}
/>
</div>
))}
</div>
</div>
);
};
export { NodeImageSlideBlock };

View file

@ -0,0 +1,49 @@
.wrap {
overflow: hidden;
position: relative;
min-width: 0;
width: 100%;
transition: height 0.25s;
border-radius: $radius $radius 0 0;
}
.image_container {
background: $node_image_bg;
border-radius: $panel_radius 0 0 $panel_radius;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
user-select: none;
will-change: transform, height;
.image {
max-height: 960px;
max-width: 100%;
opacity: 1;
border-radius: $radius $radius 0 0;
}
}
.image_wrap {
width: 100%;
// top: 0;
// left: 0;
// opacity: 0;
pointer-events: none;
touch-action: none;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
&:global(.is_active) {
opacity: 1;
}
}
.placeholder {
background: red;
height: 320px;
}

View file

@ -1,48 +1,79 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import React, { FC, useCallback, useEffect, useRef, useState, memo } from 'react';
import * as styles from './styles.scss';
import { INode } from '~/redux/types';
import { createPortal } from 'react-dom';
import { NodePanelInner } from '~/components/node/NodePanelInner';
import pick from 'ramda/es/pick';
interface IProps {
node: INode;
node: Partial<INode>;
layout: {};
can_edit: boolean;
can_like: boolean;
can_star: boolean;
onEdit: () => void;
onLike: () => void;
onStar: () => void;
}
const NodePanel: FC<IProps> = ({ node, layout }) => {
const [stack, setStack] = useState(false);
const NodePanel: FC<IProps> = memo(
({ node, layout, can_edit, can_like, can_star, onEdit, onLike, onStar }) => {
const [stack, setStack] = useState(false);
const ref = useRef(null);
const getPlace = useCallback(() => {
if (!ref.current) return;
const ref = useRef(null);
const getPlace = useCallback(() => {
if (!ref.current) return;
const { offsetTop } = ref.current;
const { height } = ref.current.getBoundingClientRect();
const { scrollY, innerHeight } = window;
const { offsetTop } = ref.current;
const { height } = ref.current.getBoundingClientRect();
const { scrollY, innerHeight } = window;
setStack(offsetTop > scrollY + innerHeight - height);
}, [ref]);
setStack(offsetTop > scrollY + innerHeight - height);
}, [ref]);
useEffect(() => {
getPlace();
window.addEventListener('scroll', getPlace);
window.addEventListener('resize', getPlace);
useEffect(() => {
getPlace();
window.addEventListener('scroll', getPlace);
window.addEventListener('resize', getPlace);
return () => {
window.removeEventListener('scroll', getPlace);
window.removeEventListener('resize', getPlace);
};
}, [layout]);
return () => {
window.removeEventListener('scroll', getPlace);
window.removeEventListener('resize', getPlace);
};
}, [layout]);
return (
<div className={styles.place} ref={ref}>
{stack ? (
createPortal(<NodePanelInner node={node} stack />, document.body)
) : (
<NodePanelInner node={node} />
)}
</div>
);
};
return (
<div className={styles.place} ref={ref}>
{stack ? (
createPortal(
<NodePanelInner
node={node}
stack
onEdit={onEdit}
onLike={onLike}
onStar={onStar}
can_edit={can_edit}
can_like={can_like}
can_star={can_star}
/>,
document.body
)
) : (
<NodePanelInner
node={node}
onEdit={onEdit}
onLike={onLike}
onStar={onStar}
can_edit={can_edit}
can_like={can_like}
can_star={can_star}
/>
)}
</div>
);
}
);
export { NodePanel };

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import React, { FC } from 'react';
import * as styles from './styles.scss';
import { Group } from '~/components/containers/Group';
import { Filler } from '~/components/containers/Filler';
@ -7,27 +7,61 @@ import { INode } from '~/redux/types';
import classNames from 'classnames';
interface IProps {
node: INode;
node: Partial<INode>;
stack?: boolean;
can_edit: boolean;
can_like: boolean;
can_star: boolean;
onEdit: () => void;
onLike: () => void;
onStar: () => void;
}
const NodePanelInner: FC<IProps> = ({ node: { title, user }, stack }) => {
const NodePanelInner: FC<IProps> = ({
node: { title, user, is_liked, is_heroic },
stack,
can_star,
can_edit,
can_like,
onStar,
onEdit,
onLike,
}) => {
return (
<div className={classNames(styles.wrap, { stack })}>
<div className={styles.content}>
<Group horizontal className={styles.panel}>
<Filler>
<div className={styles.title}>{title || '...'}</div>
{user && user.username && <div className={styles.name}>~ {user.username}</div>}
{user && user.username && <div className={styles.name}>~{user.username}</div>}
</Filler>
</Group>
<div className={styles.buttons}>
<Icon icon="edit" size={24} />
<div className={styles.sep} />
<Icon icon="heart" size={24} />
{can_star && (
<div className={classNames(styles.star, { is_heroic })}>
{is_heroic ? (
<Icon icon="star_full" size={24} onClick={onStar} />
) : (
<Icon icon="star" size={24} onClick={onStar} />
)}
</div>
)}
{can_edit && (
<div>
<Icon icon="edit" size={24} onClick={onEdit} />
</div>
)}
{can_like && (
<div className={classNames(styles.like, { is_liked })}>
{is_liked ? (
<Icon icon="heart_full" size={24} onClick={onLike} />
) : (
<Icon icon="heart" size={24} onClick={onLike} />
)}
</div>
)}
</div>
</div>
</div>

View file

@ -27,6 +27,7 @@
padding: $gap;
background: $node_bg;
height: 72px;
@include outer_shadow();
}
.title {
@ -65,22 +66,43 @@
& > * {
margin: 0 $gap;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
svg {
fill: darken(white, 50%);
transition: fill 0.25s;
}
&:hover {
svg {
fill: $red;
}
}
&::after {
content: ' ';
flex: 0 0 6px;
height: $gap;
width: 6px;
border-radius: 4px;
background: transparentize(black, 0.7);
margin-left: $gap * 2;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
&::after {
display: none;
}
}
}
//height: 54px;
//border-radius: $radius $radius 0 0;
//background: linear-gradient(176deg, #f42a00, #5c1085);
//position: absolute;
//bottom: 0;
//right: 10px;
//width: 270px;
//display: flex;
}
.mark {
@ -94,16 +116,68 @@
right: 4px;
width: 24px;
height: 52px;
background: $green_gradient;
background: $main_gradient;
box-shadow: transparentize(black, 0.8) 4px 2px;
border-radius: 2px;
}
}
.sep {
flex: 0 0 6px;
height: 6px;
width: 6px;
border-radius: 4px;
background: transparentize(black, 0.7);
}
@keyframes pulse {
0% {
transform: scale(1);
}
45% {
transform: scale(1);
}
60% {
transform: scale(1.4);
}
75% {
transform: scale(1);
}
90% {
transform: scale(1.4);
}
100% {
transform: scale(1);
}
}
.like {
transition: fill, stroke 0.25s;
will-change: transform;
&:global(.is_liked) {
svg {
fill: $red;
}
}
&:hover {
fill: $red;
animation: pulse 0.75s infinite;
}
}
.star {
transition: fill, stroke 0.25s;
will-change: transform;
&:global(.is_heroic) {
svg {
fill: $orange;
}
}
&:hover {
fill: $orange;
}
}

View file

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

View file

@ -7,19 +7,16 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto;
grid-auto-rows: auto;
grid-column-gap: $gap;
grid-row-gap: $gap;
}
.item {
background: darken($content_bg, 2%);
padding-bottom: 100%;
border-radius: $cell_radius;
@include tablet {
grid-template-columns: repeat(6, 1fr);
}
}
.title {
font: $font_14_semibold;
text-transform: uppercase;

View file

@ -0,0 +1,35 @@
import React, { FC, memo, useCallback, useState } from 'react';
import * as styles from './styles.scss';
import classNames from 'classnames';
import { INode } from '~/redux/types';
import { URLS } from '~/constants/urls';
import { RouteComponentProps, withRouter } from 'react-router';
import { getURL } from '~/utils/dom';
type IProps = RouteComponentProps & {
item: Partial<INode>;
};
const NodeRelatedItemUnconnected: FC<IProps> = memo(({ item, history }) => {
const [is_loaded, setIsLoaded] = useState(false);
const onClick = useCallback(() => history.push(URLS.NODE_URL(item.id)), [item, history]);
return (
<div
className={classNames(styles.item, { [styles.is_loaded]: is_loaded })}
key={item.id}
onClick={onClick}
>
<div
className={styles.thumb}
style={{ backgroundImage: `url("${getURL({ url: item.thumbnail })}")` }}
/>
<img src={getURL({ url: item.thumbnail })} alt="loader" onLoad={() => setIsLoaded(true)} />
</div>
);
});
const NodeRelatedItem = withRouter(NodeRelatedItemUnconnected);
export { NodeRelatedItem };

View file

@ -0,0 +1,30 @@
.item {
background: lighten($content_bg, 2%) 50% 50% no-repeat;
padding-bottom: 100%;
border-radius: $cell_radius;
cursor: pointer;
position: relative;
img {
position: absolute;
width: 0;
height: 0;
opacity: 0;
}
}
.thumb {
position: absolute;
width: 100%;
height: 100%;
border-radius: $cell_radius;
background: lighten($content_bg, 2%) 50% 50% no-repeat;
background-size: cover;
opacity: 0;
transition: opacity 0.5s;
will-change: opacity;
.is_loaded & {
opacity: 1;
}
}

View file

@ -1,4 +1,4 @@
import React, { FC } from 'react';
import React, { FC, memo } from 'react';
import { Tags } from '../Tags';
import { ITag } from '~/redux/types';
@ -8,8 +8,8 @@ interface IProps {
onChange?: (tags: string[]) => void;
}
const NodeTags: FC<IProps> = ({ is_editable, tags, onChange }) => (
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange }) => (
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
);
));
export { NodeTags };

View file

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { INode } from '~/redux/types';
import path from 'ramda/es/path';
import { formatText } from '~/utils/dom';
import * as styles from './styles.scss';
interface IProps {
node: INode;
}
const NodeTextBlock: FC<IProps> = ({ node }) => (
<div
className={styles.text}
dangerouslySetInnerHTML={{
__html: formatText(path(['blocks', 0, 'text'], node)),
}}
/>
);
export { NodeTextBlock };

View file

@ -0,0 +1,13 @@
.text {
@include outer_shadow();
background: $content_bg;
padding: $gap * 4;
border-radius: $radius;
p {
margin: $gap 0;
font-size: 18px;
line-height: 24px;
}
}

View file

@ -0,0 +1,36 @@
import React, { FC, useMemo } from 'react';
import { INode } from '~/redux/types';
import * as styles from './styles.scss';
import path from 'ramda/es/path';
interface IProps {
node: INode;
}
const NodeVideoBlock: FC<IProps> = ({ node }) => {
const video = useMemo(() => {
const url: string = path(['blocks', 0, 'url'], node);
const match =
url &&
url.match(
/http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?/
);
return match && match[1];
}, [node]);
return (
<div className={styles.wrap}>
<iframe
width="560"
height="315"
src={`https://www.youtube.com/embed/${video}`}
frameBorder="0"
allowFullScreen
title="video"
/>
</div>
);
};
export { NodeVideoBlock };

View file

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

View file

@ -1,4 +1,6 @@
.tag {
@include outer_shadow();
height: $tag_height;
background: $tag_bg;
display: flex;
@ -9,8 +11,8 @@
font: $font_14_semibold;
align-self: flex-start;
padding: 0 8px 0 0;
box-shadow: $shadow_depth_2;
margin: ($gap / 2) $gap ($gap / 2) 0;
// box-shadow: $shadow_depth_2;
margin: 0 $gap $gap 0;
position: relative;
&:global(.is_hoverable) {
@ -64,7 +66,7 @@
top: 0;
bottom: 0;
width: 100%;
padding-left: 23px;
padding-left: $tag_height;
padding-right: 5px;
box-sizing: border-box;
}
@ -74,10 +76,10 @@
width: $tag_height;
height: $tag_height;
display: flex;
margin-right: 3px;
// padding-right: 0px;
align-items: center;
justify-content: center;
flex: 0 0 22px;
flex: 0 0 $tag_height;
&::after {
content: ' ';

View file

@ -12,6 +12,7 @@ import { TagField } from '~/components/containers/TagField';
import { ITag } from '~/redux/types';
import { Tag } from '~/components/node/Tag';
import uniq from 'ramda/es/uniq';
import assocPath from 'ramda/es/assocPath';
type IProps = HTMLAttributes<HTMLDivElement> & {
tags: Partial<ITag>[];
@ -65,9 +66,14 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, ...props })
);
const onSubmit = useCallback(() => {
if (!data.length) return;
onTagsChange(uniq([...tags, ...data]).map(tag => tag.title));
}, [tags, data, onTagsChange]);
const title = input && input.trim();
const items = title ? [...data, { title }] : data;
if (!items.length) return;
setData(items);
setInput('');
onTagsChange(uniq([...tags, ...items]).map(tag => tag.title));
}, [tags, data, onTagsChange, input, setInput]);
useEffect(() => {
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));